diff --git a/.editorconfig b/.editorconfig index 8e3788746..cb758f52e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ charset = utf-8 indent_style = space indent_size = 4 -[*.{vue,html,css,js,json,yml}] +[*.{vue,html,css,js,json,yml,ts}] charset = utf-8 indent_style = space indent_size = 2 diff --git a/backend/requirements.txt b/backend/requirements.txt index 0d6e417ed..55057f646 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -19,7 +19,7 @@ nh3==0.2.17 python-dotenv==1.0.1 python-multipart==0.0.9 PyJWT==2.6.0 -pydantic==2.7.4 +pydantic[email]==2.7.4 sentry-sdk==2.7.1 starlette-context==0.3.6 sqlalchemy-utils==0.41.2 diff --git a/backend/src/appointment/controller/auth.py b/backend/src/appointment/controller/auth.py index 31d6bd6b9..4ce5acb22 100644 --- a/backend/src/appointment/controller/auth.py +++ b/backend/src/appointment/controller/auth.py @@ -7,6 +7,7 @@ import hashlib import hmac import datetime +import urllib.parse from sqlalchemy.orm import Session @@ -47,11 +48,13 @@ def signed_url_by_subscriber(subscriber: schemas.Subscriber): if not short_url: short_url = base_url + url_safe_username = urllib.parse.quote_plus(subscriber.username) + # We sign with a different hash that the end-user doesn't have access to # We also need to use the default url, as short urls are currently setup as a redirect - url = f'{base_url}/{subscriber.username}/{subscriber.short_link_hash}' + url = f'{base_url}/{url_safe_username}/{subscriber.short_link_hash}' signature = sign_url(url) # We return with the signed url signature - return f'{short_url}/{subscriber.username}/{signature}' + return f'{short_url}/{url_safe_username}/{signature}' diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 578297d5e..0c437eb65 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -139,6 +139,8 @@ class Subscriber(HasSoftDelete, Base): # 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)) + ftue_level = Column(Integer, nullable=False, default=0, index=True) + calendars = relationship('Calendar', cascade='all,delete', back_populates='owner') slots = relationship('Slot', cascade='all,delete', back_populates='subscriber') external_connections = relationship('ExternalConnections', cascade='all,delete', back_populates='owner') @@ -153,6 +155,11 @@ def preferred_email(self): """Returns the preferred email address.""" return self.secondary_email if self.secondary_email is not None else self.email + @property + def is_setup(self) -> bool: + """Has the user been through the First Time User Experience?""" + return self.ftue_level > 0 + class Calendar(Base): __tablename__ = 'calendars' diff --git a/backend/src/appointment/database/repo/subscriber.py b/backend/src/appointment/database/repo/subscriber.py index cf660f2b4..efd8424b2 100644 --- a/backend/src/appointment/database/repo/subscriber.py +++ b/backend/src/appointment/database/repo/subscriber.py @@ -5,6 +5,7 @@ import re import datetime +import urllib.parse from sqlalchemy.orm import Session from .. import models, schemas @@ -125,6 +126,8 @@ def verify_link(db: Session, url: str): """ username, signature, clean_url = utils.retrieve_user_url_data(url) + username = urllib.parse.unquote_plus(username) + subscriber = get_by_username(db, username) if not subscriber: return False diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 82ab41f7d..f40b752e8 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -155,7 +155,7 @@ class Config: class ScheduleBase(BaseModel): active: bool | None = True - name: str = Field(min_length=1) + name: str = Field(min_length=1, max_length=128) slug: Optional[str] = None calendar_id: int location_type: LocationType | None = LocationType.inperson @@ -250,15 +250,16 @@ class Invite(BaseModel): class SubscriberIn(BaseModel): timezone: str | None = None - username: str - name: str | None = None + username: str = Field(min_length=1, max_length=128) + name: Optional[str] = Field(min_length=1, max_length=128, default=None) avatar_url: str | None = None secondary_email: str | None = None class SubscriberBase(SubscriberIn): - email: str + email: str = Field(min_length=1, max_length=200) preferred_email: str | None = None + is_setup: bool | None = None level: SubscriberLevel | None = SubscriberLevel.basic @@ -270,6 +271,7 @@ class Subscriber(SubscriberAuth): id: int calendars: list[Calendar] = [] slots: list[Slot] = [] + ftue_level: Optional[int] = Field(gte=0) class Config: from_attributes = True diff --git a/backend/src/appointment/migrations/versions/2024_06_25_2133-156b3b0d77b9_add_ftue_level_to_subscribers.py b/backend/src/appointment/migrations/versions/2024_06_25_2133-156b3b0d77b9_add_ftue_level_to_subscribers.py new file mode 100644 index 000000000..9dd171fa6 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_06_25_2133-156b3b0d77b9_add_ftue_level_to_subscribers.py @@ -0,0 +1,24 @@ +"""add ftue_level to subscribers + +Revision ID: 156b3b0d77b9 +Revises: 12c7e1b34dd6 +Create Date: 2024-06-25 21:33:27.094632 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '156b3b0d77b9' +down_revision = '12c7e1b34dd6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('subscribers', sa.Column('ftue_level', sa.Integer, default=0, nullable=False, index=True)) + + +def downgrade() -> None: + op.drop_column('subscribers', 'ftue_level') diff --git a/backend/src/appointment/migrations/versions/2024_07_02_1636-0c22678e25db_.py b/backend/src/appointment/migrations/versions/2024_07_02_1636-0c22678e25db_.py new file mode 100644 index 000000000..84fd81e13 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_07_02_1636-0c22678e25db_.py @@ -0,0 +1,24 @@ +"""empty message + +Revision ID: 0c22678e25db +Revises: 156b3b0d77b9, a9ca5a4325ec +Create Date: 2024-07-02 16:36:47.372956 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0c22678e25db' +down_revision = ('156b3b0d77b9', 'a9ca5a4325ec') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 3dc4ca560..1fa959721 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -66,6 +66,7 @@ def update_me( name=me.name, level=me.level, timezone=me.timezone, + is_setup=me.is_setup, ) diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 1a1084345..c2a975437 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -292,6 +292,7 @@ def me( level=subscriber.level, timezone=subscriber.timezone, avatar_url=subscriber.avatar_url, + is_setup=subscriber.is_setup, ) diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 273664da6..ae551b5e0 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -108,6 +108,14 @@ def update_schedule( and subscriber.get_external_connection(ExternalConnectionType.zoom) is None ): raise validation.ZoomNotConnectedException() + + if schedule.slug is None: + # If slug isn't provided, give them the last 8 characters from a uuid4 + schedule.slug = repo.schedule.generate_slug(db, id) + if not schedule.slug: + # A little extra, but things are a little out of place right now.. + raise validation.ScheduleCreationException() + return repo.schedule.update(db=db, schedule=schedule, schedule_id=id) @@ -435,7 +443,7 @@ def decide_on_schedule_availability_slot( title=title, start=slot.start.replace(tzinfo=timezone.utc), end=slot.start.replace(tzinfo=timezone.utc) + timedelta(minutes=slot.duration), - description=schedule.details, + description=schedule.details or '', location=schemas.EventLocation( type=schedule.location_type, url=location_url, diff --git a/backend/src/appointment/routes/subscriber.py b/backend/src/appointment/routes/subscriber.py index 4825aba04..e16d6fe2f 100644 --- a/backend/src/appointment/routes/subscriber.py +++ b/backend/src/appointment/routes/subscriber.py @@ -4,13 +4,16 @@ from ..database import repo, schemas, models from ..database.models import Subscriber -from ..dependencies.auth import get_admin_subscriber +from ..dependencies.auth import get_admin_subscriber, get_subscriber from ..dependencies.database import get_db from ..exceptions import validation router = APIRouter() +""" ADMIN ROUTES +These require get_admin_subscriber! +""" @router.get('/', response_model=list[schemas.SubscriberAdminOut]) def get_all_subscriber(db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)): @@ -47,3 +50,16 @@ def enable_subscriber(email: str, db: Session = Depends(get_db), _: Subscriber = # Set active flag to true on the subscribers model. return repo.subscriber.enable(db, subscriber_to_enable) + + +""" NON-ADMIN ROUTES """ + + +@router.post('/setup') +def subscriber_is_setup(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): + """Flips ftue_level to 1""" + subscriber.ftue_level = 1 + db.add(subscriber) + db.commit() + + return True diff --git a/docker-compose.yml b/docker-compose.yml index 42c799569..575d886c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: build: ./frontend volumes: - './frontend:/app' - - '/app/node_modules' + - 'node_modules:/app/node_modules' ports: - 8080:8080 environment: @@ -54,3 +54,4 @@ services: volumes: db: {} cache: {} + node_modules: {} diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 95bc8a8b4..8a4d03172 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -6,9 +6,11 @@ module.exports = { browser: true, es2021: true, }, + parser: '@typescript-eslint/parser', extends: [ 'plugin:vue/vue3-essential', 'plugin:tailwindcss/recommended', + 'plugin:@typescript-eslint/recommended', 'airbnb-base', ], overrides: [], @@ -18,6 +20,7 @@ module.exports = { }, plugins: [ 'vue', + '@typescript-eslint', ], rules: { 'import/extensions': ['error', 'ignorePackages', { @@ -31,6 +34,18 @@ module.exports = { 'tailwindcss/no-custom-classname': 'off', 'import/prefer-default-export': 'off', radix: 'off', + '@typescript-eslint/no-explicit-any': 'off', + // Disable full warning, and customize the typescript one + // Warn about unused vars unless they start with an underscore + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, settings: { 'import/resolver': { diff --git a/frontend/README.md b/frontend/README.md index 32ac9d0ae..18d155625 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -24,6 +24,19 @@ yarn run serve yarn run build ``` +### Post-CSS + +We use post-css to enhance our css. Any post-css that isn't in a SFC must be in a `.pcss` file and imported into the scoped style like so: +```css +@import '@/assets/styles/custom-media.pcss'; + +@media (--md) { + .container { + ... + } +} +``` + ### Lints and fixes files Frontend is formatted using ESlint with airbnb rules. diff --git a/frontend/index.html b/frontend/index.html index 3e4d6e951..b2de56838 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,10 +6,12 @@ Thunderbird Appointment + diff --git a/frontend/src/components/FTUE/ConnectVideo.vue b/frontend/src/components/FTUE/ConnectVideo.vue new file mode 100644 index 000000000..4d19270b5 --- /dev/null +++ b/frontend/src/components/FTUE/ConnectVideo.vue @@ -0,0 +1,198 @@ + + + + diff --git a/frontend/src/components/FTUE/Finish.vue b/frontend/src/components/FTUE/Finish.vue new file mode 100644 index 000000000..eec45538e --- /dev/null +++ b/frontend/src/components/FTUE/Finish.vue @@ -0,0 +1,119 @@ + + + + diff --git a/frontend/src/components/FTUE/GooglePermissions.vue b/frontend/src/components/FTUE/GooglePermissions.vue new file mode 100644 index 000000000..9fa426e00 --- /dev/null +++ b/frontend/src/components/FTUE/GooglePermissions.vue @@ -0,0 +1,169 @@ + + + + diff --git a/frontend/src/components/FTUE/SetupProfile.vue b/frontend/src/components/FTUE/SetupProfile.vue new file mode 100644 index 000000000..da8af13e8 --- /dev/null +++ b/frontend/src/components/FTUE/SetupProfile.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/frontend/src/components/FTUE/SetupSchedule.vue b/frontend/src/components/FTUE/SetupSchedule.vue new file mode 100644 index 000000000..c6749e50c --- /dev/null +++ b/frontend/src/components/FTUE/SetupSchedule.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/src/components/ScheduleCreation.vue b/frontend/src/components/ScheduleCreation.vue index 3871f8bb3..d760a5d44 100644 --- a/frontend/src/components/ScheduleCreation.vue +++ b/frontend/src/components/ScheduleCreation.vue @@ -377,13 +377,14 @@ import ToolTip from '@/elements/ToolTip.vue'; import { useCalendarStore } from '@/stores/calendar-store'; import { useExternalConnectionsStore } from '@/stores/external-connections-store'; import SnackishBar from '@/elements/SnackishBar.vue'; +import { dayjsKey } from "@/keys"; // component constants const user = useUserStore(); const calendarStore = useCalendarStore(); const externalConnectionStore = useExternalConnectionsStore(); const { t } = useI18n(); -const dj = inject('dayjs'); +const dj = inject(dayjsKey); const call = inject('call'); const isoWeekdays = inject('isoWeekdays'); const dateFormat = dateFormatStrings.qalendarFullDay; @@ -717,7 +718,7 @@ watch( diff --git a/frontend/src/elements/EventPopup.vue b/frontend/src/elements/EventPopup.vue index ce7eb333d..656b1b7ba 100644 --- a/frontend/src/elements/EventPopup.vue +++ b/frontend/src/elements/EventPopup.vue @@ -43,10 +43,11 @@ import { IconClock, IconUsers, } from '@tabler/icons-vue'; +import { dayjsKey } from "@/keys"; // component constants const { t } = useI18n(); -const dj = inject('dayjs'); +const dj = inject(dayjsKey); // component properties const props = defineProps({ diff --git a/frontend/src/elements/WordMark.vue b/frontend/src/elements/WordMark.vue new file mode 100644 index 000000000..325d361d8 --- /dev/null +++ b/frontend/src/elements/WordMark.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/elements/calendar/CalendarEvent.vue b/frontend/src/elements/calendar/CalendarEvent.vue index 4071e0ce3..43343251c 100644 --- a/frontend/src/elements/calendar/CalendarEvent.vue +++ b/frontend/src/elements/calendar/CalendarEvent.vue @@ -71,8 +71,9 @@ import CalendarEventPreview from '@/elements/calendar/CalendarEventPreview'; import CalendarEventRemote from '@/elements/calendar/CalendarEventRemote'; import CalendarEventScheduled from '@/elements/calendar/CalendarEventScheduled'; import EventPopup from '@/elements/EventPopup'; +import { dayjsKey } from "@/keys"; -const dj = inject('dayjs'); +const dj = inject(dayjsKey); // component properties const props = defineProps({ diff --git a/frontend/src/elements/calendar/CalendarMiniMonthDay.vue b/frontend/src/elements/calendar/CalendarMiniMonthDay.vue index c33ca0077..a69c9f751 100644 --- a/frontend/src/elements/calendar/CalendarMiniMonthDay.vue +++ b/frontend/src/elements/calendar/CalendarMiniMonthDay.vue @@ -24,8 +24,9 @@ + diff --git a/frontend/src/tbpro/elements/NoticeBar.vue b/frontend/src/tbpro/elements/NoticeBar.vue new file mode 100644 index 000000000..386aeaf1d --- /dev/null +++ b/frontend/src/tbpro/elements/NoticeBar.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/tbpro/elements/PrimaryButton.vue b/frontend/src/tbpro/elements/PrimaryButton.vue new file mode 100644 index 000000000..1a6682c69 --- /dev/null +++ b/frontend/src/tbpro/elements/PrimaryButton.vue @@ -0,0 +1,10 @@ + + diff --git a/frontend/src/tbpro/elements/SecondaryButton.vue b/frontend/src/tbpro/elements/SecondaryButton.vue new file mode 100644 index 000000000..184f7b701 --- /dev/null +++ b/frontend/src/tbpro/elements/SecondaryButton.vue @@ -0,0 +1,10 @@ + + diff --git a/frontend/src/tbpro/elements/SelectInput.vue b/frontend/src/tbpro/elements/SelectInput.vue new file mode 100644 index 000000000..c75cc6281 --- /dev/null +++ b/frontend/src/tbpro/elements/SelectInput.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/tbpro/elements/SyncCard.vue b/frontend/src/tbpro/elements/SyncCard.vue new file mode 100644 index 000000000..984b1f0e0 --- /dev/null +++ b/frontend/src/tbpro/elements/SyncCard.vue @@ -0,0 +1,138 @@ + + + diff --git a/frontend/src/tbpro/elements/TextInput.vue b/frontend/src/tbpro/elements/TextInput.vue new file mode 100644 index 000000000..89880d909 --- /dev/null +++ b/frontend/src/tbpro/elements/TextInput.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/frontend/src/tbpro/elements/ToolTip.vue b/frontend/src/tbpro/elements/ToolTip.vue new file mode 100644 index 000000000..6a9e347ac --- /dev/null +++ b/frontend/src/tbpro/elements/ToolTip.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/tbpro/icons/NoticeCriticalIcon.vue b/frontend/src/tbpro/icons/NoticeCriticalIcon.vue new file mode 100644 index 000000000..a3f304c4c --- /dev/null +++ b/frontend/src/tbpro/icons/NoticeCriticalIcon.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/tbpro/icons/NoticeInfoIcon.vue b/frontend/src/tbpro/icons/NoticeInfoIcon.vue new file mode 100644 index 000000000..9bd9769c9 --- /dev/null +++ b/frontend/src/tbpro/icons/NoticeInfoIcon.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/tbpro/icons/NoticeSuccessIcon.vue b/frontend/src/tbpro/icons/NoticeSuccessIcon.vue new file mode 100644 index 000000000..88074fd65 --- /dev/null +++ b/frontend/src/tbpro/icons/NoticeSuccessIcon.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/tbpro/icons/NoticeWarningIcon.vue b/frontend/src/tbpro/icons/NoticeWarningIcon.vue new file mode 100644 index 000000000..5abfd0e88 --- /dev/null +++ b/frontend/src/tbpro/icons/NoticeWarningIcon.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/views/AppointmentsView.vue b/frontend/src/views/AppointmentsView.vue index ddf6eefda..98f16cf0e 100644 --- a/frontend/src/views/AppointmentsView.vue +++ b/frontend/src/views/AppointmentsView.vue @@ -222,12 +222,13 @@ import { } from '@tabler/icons-vue'; import { useAppointmentStore } from '@/stores/appointment-store'; import { storeToRefs } from 'pinia'; +import { dayjsKey } from "@/keys"; // component constants const { t } = useI18n(); const route = useRoute(); const router = useRouter(); -const dj = inject('dayjs'); +const dj = inject(dayjsKey); const refresh = inject('refresh'); const appointmentStore = useAppointmentStore(); diff --git a/frontend/src/views/BookingView.vue b/frontend/src/views/BookingView.vue index 14fef6f68..9e6a9041b 100644 --- a/frontend/src/views/BookingView.vue +++ b/frontend/src/views/BookingView.vue @@ -61,10 +61,11 @@ import BookingViewSlotSelection from '@/components/bookingView/BookingViewSlotSe import BookingViewSuccess from '@/components/bookingView/BookingViewSuccess.vue'; import BookingViewError from '@/components/bookingView/BookingViewError.vue'; import { useRoute } from 'vue-router'; +import { dayjsKey } from "@/keys"; // component constants const { t } = useI18n(); -const dj = inject('dayjs'); +const dj = inject(dayjsKey); const call = inject('call'); const bookingViewStore = useBookingViewStore(); const bookingModalStore = useBookingModalStore(); diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 319f0defc..40224005f 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -83,11 +83,12 @@ import { useCalendarStore } from '@/stores/calendar-store'; import { useAppointmentStore } from '@/stores/appointment-store'; import { storeToRefs } from 'pinia'; import CalendarQalendar from '@/components/CalendarQalendar.vue'; +import { dayjsKey } from "@/keys"; const { t } = useI18n(); const route = useRoute(); const router = useRouter(); -const dj = inject('dayjs'); +const dj = inject(dayjsKey); const call = inject('call'); const refresh = inject('refresh'); diff --git a/frontend/src/views/FirstTimeUserExperienceView.vue b/frontend/src/views/FirstTimeUserExperienceView.vue new file mode 100644 index 000000000..bfc301aad --- /dev/null +++ b/frontend/src/views/FirstTimeUserExperienceView.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 20ce5ff32..e65c55ada 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -102,6 +102,7 @@ import { useRouter } from 'vue-router'; import { useUserStore } from '@/stores/user-store'; import PrimaryButton from '@/elements/PrimaryButton'; import AlertBox from '@/elements/AlertBox'; +import { dayjsKey } from "@/keys"; // component constants const user = useUserStore(); @@ -109,7 +110,7 @@ const user = useUserStore(); // component constants const { t } = useI18n(); const call = inject('call'); -const dj = inject('dayjs'); +const dj = inject(dayjsKey); const router = useRouter(); const isPasswordAuth = inject('isPasswordAuth'); const isFxaAuth = inject('isFxaAuth'); diff --git a/frontend/src/views/PostLoginView.vue b/frontend/src/views/PostLoginView.vue index a43968912..ed1b7e193 100644 --- a/frontend/src/views/PostLoginView.vue +++ b/frontend/src/views/PostLoginView.vue @@ -7,7 +7,7 @@