Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User store extended settings store #828

Merged
merged 20 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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') ?? '{}');
devmount marked this conversation as resolved.
Show resolved Hide resolved
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());
devmount marked this conversation as resolved.
Show resolved Hide resolved
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();
devmount marked this conversation as resolved.
Show resolved Hide resolved

// 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();
devmount marked this conversation as resolved.
Show resolved Hide resolved
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
Loading