Skip to content

Commit

Permalink
User store extended settings store (#828)
Browse files Browse the repository at this point in the history
* ➕ Extend subscriber model with additional settings

* ➕ Extend user store with additional settings

* 🔨 Move settings related functions from utils

* 🔨 Replace deprecated exists function with authenticated

* 🔨 Use init call for whole user store

* 🔨 Load default locale from user settings

* 🔨 Only update settings on settings page

* 🔨 Fix user store tests

* 🔨 Fix user store tests (2nd try)

* 📜 Update documentation

* Update backend/src/appointment/database/models.py

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* Update backend/src/appointment/database/models.py

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* Update backend/src/appointment/database/schemas.py

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* Update backend/src/appointment/migrations/versions/2025_01_15_1340-4a15d01919b8_add_config_fields_to_subscribers_table.py

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* Update backend/src/appointment/migrations/versions/2025_01_15_1340-4a15d01919b8_add_config_fields_to_subscribers_table.py

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* Update backend/src/appointment/routes/api.py

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* Update backend/src/appointment/routes/auth.py

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* Update docs/README.md

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>

* 🔨 Fix more colour naming

* 🔨 Fix more colour naming

---------

Co-authored-by: Mel <97147377+MelissaAutumn@users.noreply.github.com>
  • Loading branch information
devmount and MelissaAutumn authored Jan 21, 2025
1 parent f0f5b10 commit dc7376a
Show file tree
Hide file tree
Showing 35 changed files with 373 additions and 246 deletions.
8 changes: 7 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pip install ruff
Commands

```bash
ruff check
ruff check
```

### Authentication
Expand Down Expand Up @@ -60,6 +60,12 @@ To generate a database migration, bash into a running backend container and run:
alembic revision -m "create ... table"
```

To roll back one migration, run:

```bash
alembic downgrade -1
```

## Commands

Backend has a light selection of cli commands available to be run inside a container.
Expand Down
20 changes: 17 additions & 3 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ class InviteStatus(enum.Enum):
revoked = 2 # The code is no longer valid and cannot be used for sign up anymore


class ColourScheme(enum.Enum):
system = 'system'
dark = 'dark'
light = 'light'


class TimeMode(enum.Enum):
h12 = 12
h24 = 24


def encrypted_type(column_type, length: int = 255, **kwargs) -> StringEncryptedType:
"""Helper to reduce visual noise when creating model columns"""
return StringEncryptedType(column_type, secret, AesEngine, 'pkcs5', length=length, **kwargs)
Expand Down Expand Up @@ -134,12 +145,15 @@ class Subscriber(HasSoftDelete, Base):

name = Column(encrypted_type(String), index=True)
level = Column(Enum(SubscriberLevel), default=SubscriberLevel.basic, index=True)
language = Column(encrypted_type(String), nullable=False, default=FALLBACK_LOCALE, index=True)
timezone = Column(encrypted_type(String), index=True)
avatar_url = Column(encrypted_type(String, length=2048), index=False)

short_link_hash = Column(encrypted_type(String), index=False)

# General settings
language = Column(encrypted_type(String), nullable=False, default=FALLBACK_LOCALE, index=True)
timezone = Column(encrypted_type(String), index=True)
colour_scheme = Column(Enum(ColourScheme), default=ColourScheme.system, nullable=False, index=True)
time_mode = Column(Enum(TimeMode), default=TimeMode.h24, nullable=False, index=True)

# Only accept the times greater than the one specified in the `iat` claim of the jwt token
minimum_valid_iat_time = Column('minimum_valid_iat_time', encrypted_type(DateTime))

Expand Down
4 changes: 4 additions & 0 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
ExternalConnectionType,
MeetingLinkProviderType,
InviteStatus,
ColourScheme,
TimeMode,
)
from .. import utils, defines

Expand Down Expand Up @@ -299,6 +301,8 @@ class SubscriberIn(BaseModel):
avatar_url: str | None = None
secondary_email: str | None = None
language: str | None = FALLBACK_LOCALE
colour_scheme: ColourScheme = ColourScheme.system
time_mode: TimeMode = TimeMode.h24


class SubscriberBase(SubscriberIn):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Add config fields to subscribers table
Revision ID: 4a15d01919b8
Revises: 0c99f6a02f3b
Create Date: 2025-01-15 13:40:12.022117
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import StringEncryptedType
from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
from appointment.database.models import ColourScheme, TimeMode


def secret():
return os.getenv('DB_SECRET')

# revision identifiers, used by Alembic.
revision = '4a15d01919b8'
down_revision = '0c99f6a02f3b'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('subscribers', sa.Column('colour_scheme', sa.Enum(ColourScheme), default=ColourScheme.system, nullable=False, index=True))
op.add_column('subscribers', sa.Column('time_mode', sa.Enum(TimeMode), default=TimeMode.h24, nullable=False, index=True))


def downgrade() -> None:
op.drop_column('subscribers', 'colour_scheme')
op.drop_column('subscribers', 'time_mode')
5 changes: 4 additions & 1 deletion backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ def update_me(
is_setup=me.is_setup,
avatar_url=me.avatar_url,
schedule_links=schedule_links_by_subscriber(db, subscriber),
unique_hash=me.unique_hash
unique_hash=me.unique_hash,
language=me.language,
colour_scheme=me.colour_scheme,
time_mode=me.time_mode,
)


Expand Down
3 changes: 3 additions & 0 deletions backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,9 @@ def me(
is_setup=subscriber.is_setup,
schedule_links=schedule_links_by_subscriber(db, subscriber),
unique_hash=hash,
language=subscriber.language,
colour_scheme=subscriber.colour_scheme,
time_mode=subscriber.time_mode,
)


Expand Down
6 changes: 4 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,18 @@ erDiagram
string email "FxA account email and email used for password auth"
string name "Preferred display name"
enum level "Subscription level [basic, plus, pro, admin]"
int timezone "User selected home timezone, UTC offset"
string avatar_url "Public link to an avatar image"
string short_link_hash "Hash for verifying user link"
string language "Lang code for subscribers preferred locale"
int timezone "User selected home timezone, UTC offset"
enum colour_scheme "Frontend theme [system, dark, light]"
enum time_mode "Format for displaying times [h12, h24]"
string minimum_valid_iat_time "Minimum valid time to accept for JWT tokens"
date time_created "UTC timestamp of subscriber creation"
date time_updated "UTC timestamp of last subscriber modification"
string secondary_email "Secondary email address"
date time_deleted "UTC timestamp of deletion (soft delete)"
int ftue_level "Version of the FTUE the user has completed"
string language "Lang code for subscribers preferred locale"
}
SUBSCRIBERS ||--o{ CALENDARS : own
CALENDARS {
Expand Down
10 changes: 7 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thunderbird Appointment</title>
<script>
// handle theme color scheme
// Load initial theme color scheme from user settings
const user = JSON.parse(localStorage?.getItem('tba/user') ?? '{}');
const browserPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

if (
(localStorage?.getItem('theme') === 'dark'
|| (!localStorage?.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches))
(user?.settings?.colourScheme === 'dark'
|| (user?.settings?.colourScheme === 'system' && browserPrefersDark)
|| (!user?.settings?.colourScheme && browserPrefersDark))
) {
document.documentElement.classList.add('dark');
} else {
Expand Down
35 changes: 17 additions & 18 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import { getPreferredTheme } from '@/utils';
import {
apiUrlKey, callKey, refreshKey, isPasswordAuthKey, isFxaAuthKey, fxaEditProfileUrlKey, hasProfanityKey,
} from '@/keys';
import { defaultLocale } from '@/utils';
import { StringResponse } from '@/models';
import { usePosthog, posthog } from '@/composables/posthog';
import UAParser from 'ua-parser-js';
Expand All @@ -29,12 +29,12 @@ import { useAppointmentStore } from '@/stores/appointment-store';
import { useScheduleStore } from '@/stores/schedule-store';
// component constants
const currentUser = useUserStore(); // data: { username, email, name, level, timezone, id }
const user = useUserStore();
const apiUrl = inject(apiUrlKey);
const route = useRoute();
const routeName = typeof route.name === 'string' ? route.name : '';
const router = useRouter();
const lang = localStorage?.getItem('locale') ?? navigator.language.split('-')[0];
const lang = defaultLocale();
const siteNotificationStore = useSiteNotificationStore();
const {
Expand All @@ -59,13 +59,12 @@ const hasProfanity = (input: string) => profanity.exists(input);
provide(hasProfanityKey, hasProfanity);
// handle auth and fetch
const isAuthenticated = computed(() => currentUser?.exists());
const call = createFetch({
baseUrl: apiUrl,
options: {
beforeFetch({ options }) {
if (isAuthenticated.value) {
const token = currentUser.data.accessToken;
if (user?.authenticated) {
const token = user.data.accessToken;
// @ts-ignore
options.headers.Authorization = `Bearer ${token}`;
}
Expand Down Expand Up @@ -95,7 +94,7 @@ const call = createFetch({
);
} else if (response && response.status === 401 && data?.detail?.id === 'INVALID_TOKEN') {
// Clear current user data, and ship them to the login screen!
currentUser.$reset();
user.$reset();
await router.push('/login');
return context;
}
Expand All @@ -110,6 +109,9 @@ const call = createFetch({
},
});
// Initialize API calls for user store
user.init(call);
provide(callKey, call);
provide(isPasswordAuthKey, import.meta.env?.VITE_AUTH_SCHEME === 'password');
provide(isFxaAuthKey, import.meta.env?.VITE_AUTH_SCHEME === 'fxa');
Expand All @@ -126,7 +128,6 @@ const navItems = [
const calendarStore = useCalendarStore();
const appointmentStore = useAppointmentStore();
const scheduleStore = useScheduleStore();
const userStore = useUserStore();
// true if route can be accessed without authentication
const routeIsPublic = computed(
Expand All @@ -141,9 +142,9 @@ const routeHasModal = computed(
// retrieve calendars and appointments after checking login and persisting user to db
const getDbData = async () => {
if (currentUser?.exists()) {
if (user?.authenticated) {
await Promise.all([
userStore.profile(call),
user.profile(),
calendarStore.fetch(call),
appointmentStore.fetch(call),
scheduleStore.fetch(call),
Expand Down Expand Up @@ -175,7 +176,6 @@ const onPageLoad = async () => {
effective_resolution: effectiveDeviceRes,
user_agent: navigator.userAgent,
locale: lang,
theme: getPreferredTheme(),
}).json();
const { data } = response;
Expand Down Expand Up @@ -289,9 +289,8 @@ onMounted(async () => {
});
const id = await onPageLoad();
if (isAuthenticated.value) {
const profile = useUserStore();
posthog.identify(profile.data.uniqueHash);
if (user?.authenticated) {
posthog.identify(user.data.uniqueHash);
} else if (id) {
posthog.identify(id);
}
Expand All @@ -302,20 +301,20 @@ onMounted(async () => {

<template>
<!-- authenticated subscriber content -->
<template v-if="router.hasRoute(route.name) && (isAuthenticated || routeIsPublic)">
<template v-if="router.hasRoute(route.name) && (user?.authenticated || routeIsPublic)">
<site-notification
v-if="isAuthenticated && visibleNotification"
v-if="user?.authenticated && visibleNotification"
:title="notificationTitle"
:action-url="notificationActionUrl"
>
{{ notificationMessage }}
</site-notification>
<nav-bar v-if="isAuthenticated" :nav-items="navItems"/>
<nav-bar v-if="user?.authenticated" :nav-items="navItems"/>
<title-bar v-if="routeIsPublic"/>
<main
:class="{
'mx-4 min-h-full py-32 lg:mx-8': !routeIsHome && !routeIsPublic,
'!pt-24': routeIsHome || isAuthenticated,
'!pt-24': routeIsHome || user?.authenticated,
'min-h-full': routeIsPublic && !routeHasModal,
}"
>
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/BookingModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ const bookIt = () => {
};
onMounted(() => {
if (user.exists()) {
if (user.authenticated) {
attendee.name = user.data.name;
attendee.email = user.data.preferredEmail;
if (user.data.timezone !== null) {
attendee.timezone = user.data.timezone;
if (user.data.settings.timezone !== null) {
attendee.timezone = user.data.settings.timezone;
}
}
});
Expand Down
Loading

0 comments on commit dc7376a

Please sign in to comment.