diff --git a/README.md b/README.md index 32511caa..b1a88d5c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ Check out the project's respective readmes: This project uses [Fluent](https://projectfluent.org/) for localization. Files are located in their respective `l10n//*.ftl`. +### Self-hosting + +More information is coming soon! If you're adventurous follow the setup steps in each project. Once the project is running the first login will create a new user, any login attempts with new emails after that will check against existing credentials. + ### Deployment When changes are merged to main, a new [release](https://github.com/thunderbird/appointment/releases/) is cut, and the changes are deployed to [stage.appointment.day](https://stage.appointment.day/). diff --git a/backend/.env.example b/backend/.env.example index 39631eed..437deb8a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,7 @@ APP_ENV=dev # List of comma separated admin usernames. USE WITH CAUTION! Those can do serious damage to the data. APP_ADMIN_ALLOW_LIST= APP_SETUP +APP_ALLOW_FIRST_TIME_REGISTER= # -- FRONTEND -- FRONTEND_URL=http://localhost:8080 diff --git a/backend/README.md b/backend/README.md index bc739ffc..28b630ea 100644 --- a/backend/README.md +++ b/backend/README.md @@ -8,6 +8,10 @@ This is the backend component of Thunderbird Appointment written in Python using More information will be provided in the future. There is currently a docker file provided which we use to deploy to AWS' ECS which should help you get started. +In order to create a user with password authentication mode, you will need to set `APP_ALLOW_FIRST_TIME_REGISTER=True` in your `.env`. + +After the first login you'll want to fill the `APP_ADMIN_ALLOW_LIST` env variable with your account's email to access the basic admin panel located at `/admin/subscribers`. + ### Configuration The backend project uses dotenv files to inject environment variables into the application. A starting template can be found as [.env.example](.env.example). Copy that as your `.env` to get started. diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 2e472169..29b431d7 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -287,6 +287,7 @@ class Schedule(Base): weekdays: str | dict = Column(JSON, default='[1,2,3,4,5]') # list of ISO weekdays, Mo-Su => 1-7 slot_duration: int = Column(Integer, default=30) # defaults to 30 minutes booking_confirmation: bool = Column(Boolean, index=True, nullable=False, default=True) + timezone: str = Column(encrypted_type(String), index=True, nullable=True) # Not used right now but will be in the future # What (if any) meeting link will we generate once the meeting is booked meeting_link_provider: MeetingLinkProviderType = Column( diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index d34b91e7..2edc82a0 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -4,6 +4,7 @@ """ import json +import zoneinfo from uuid import UUID from datetime import datetime, date, time, timezone, timedelta from typing import Annotated, Optional, Self @@ -175,6 +176,7 @@ class ScheduleBase(BaseModel): slot_duration: int | None = None meeting_link_provider: MeetingLinkProviderType | None = MeetingLinkProviderType.none booking_confirmation: bool = True + timezone: Optional[str] = None class Config: json_encoders = { @@ -208,8 +210,13 @@ class ScheduleValidationIn(ScheduleBase): def start_time_should_be_before_end_time(self) -> Self: # Can't have the end time before the start time! # (Well you can, it will roll over to the next day, but the ux is poor!) - start_time = datetime.combine(self.start_date, self.start_time, tzinfo=timezone.utc) - end_time = datetime.combine(self.start_date, self.end_time, tzinfo=timezone.utc) + # Note we have to convert to the local timezone for this to work... + + # Fallback to utc... + tz = self.timezone or 'UTC' + + start_time = datetime.combine(self.start_date, self.start_time, tzinfo=timezone.utc).astimezone(zoneinfo.ZoneInfo(tz)) + end_time = datetime.combine(self.start_date, self.end_time, tzinfo=timezone.utc).astimezone(zoneinfo.ZoneInfo(tz)) start_time = (start_time + timedelta(minutes=self.slot_duration)).time() end_time = end_time.time() diff --git a/backend/src/appointment/migrations/versions/2024_10_08_1615-502d0217a555_add_timezone_to_schedule.py b/backend/src/appointment/migrations/versions/2024_10_08_1615-502d0217a555_add_timezone_to_schedule.py new file mode 100644 index 00000000..495a05b5 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2024_10_08_1615-502d0217a555_add_timezone_to_schedule.py @@ -0,0 +1,25 @@ +"""add timezone to schedule + +Revision ID: 502d0217a555 +Revises: 01d80f00243f +Create Date: 2024-10-08 16:15:22.157158 + +""" +from alembic import op +import sqlalchemy as sa + +from appointment.database.models import encrypted_type + +# revision identifiers, used by Alembic. +revision = '502d0217a555' +down_revision = '01d80f00243f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('schedules', sa.Column('timezone', encrypted_type(sa.String), nullable=True, index=True)) + + +def downgrade() -> None: + op.drop_column('schedules', 'timezone') diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 76677fa2..11e80a15 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -31,6 +31,7 @@ from ..exceptions.fxa_api import NotInAllowListException from ..l10n import l10n from ..tasks.emails import send_confirm_email +from ..utils import get_password_hash router = APIRouter() @@ -46,6 +47,24 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): return encoded_jwt +def create_subscriber(db, email, password, timezone): + subscriber = repo.subscriber.create(db, schemas.SubscriberBase( + email=email, + username=email, + name=email.split('@')[0], + timezone=timezone + )) + + # Update with password + subscriber.password = get_password_hash(password) + + db.add(subscriber) + db.commit() + db.refresh(subscriber) + + return subscriber + + @router.post('/can-login') def can_login( data: schemas.CheckEmail, @@ -271,6 +290,12 @@ def token( if os.getenv('AUTH_SCHEME') == 'fxa': raise HTTPException(status_code=405) + has_subscribers = db.query(Subscriber).count() + + if os.getenv('APP_ALLOW_FIRST_TIME_REGISTER') == 'True' and has_subscribers == 0: + # Create an initial subscriber based with the UTC timezone, the FTUE will give them a change to adjust this + create_subscriber(db, form_data.username, form_data.password, 'UTC') + """Retrieve an access token from a given email (=username) and password.""" subscriber = repo.subscriber.get_by_email(db, form_data.username) if not subscriber or subscriber.password is None: @@ -347,27 +372,3 @@ def permission_check(subscriber: Subscriber = Depends(get_admin_subscriber)): raise validation.InvalidPermissionLevelException() return True # Covered by get_admin_subscriber - -# @router.get('/test-create-account') -# def test_create_account(email: str, password: str, timezone: str, db: Session = Depends(get_db)): -# """Used to create a test account""" -# if os.getenv('APP_ENV') != 'dev': -# raise HTTPException(status_code=405) -# if os.getenv('AUTH_SCHEME') != 'password': -# raise HTTPException(status_code=405) -# -# subscriber = repo.subscriber.create(db, schemas.SubscriberBase( -# email=email, -# username=email, -# name=email.split('@')[0], -# timezone=timezone -# )) -# -# # Update with password -# subscriber.password = get_password_hash(password) -# -# db.add(subscriber) -# db.commit() -# db.refresh(subscriber) -# -# return subscriber diff --git a/backend/test/factory/schedule_factory.py b/backend/test/factory/schedule_factory.py index ac300f5a..64ee7c22 100644 --- a/backend/test/factory/schedule_factory.py +++ b/backend/test/factory/schedule_factory.py @@ -26,6 +26,7 @@ def _make_schedule( meeting_link_provider=models.MeetingLinkProviderType.none, slug=FAKER_RANDOM_VALUE, booking_confirmation=True, + timezone='UTC' ): with with_db() as db: return repo.schedule.create( @@ -52,6 +53,7 @@ def _make_schedule( calendar_id=calendar_id if factory_has_value(calendar_id) else make_caldav_calendar(connected=True).id, + timezone=timezone, ), ) diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index a71eae3d..05cf07e1 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -87,6 +87,48 @@ def test_token(self, with_db, with_client, make_pro_subscriber): ) assert response.status_code == 403, response.text + def test_token_creates_user(self, with_db, with_client): + with with_db() as db: + # Remove all subscribers + for sub in db.query(models.Subscriber).all(): + db.delete(sub) + db.commit() + + email = 'greg@example.com' + password = 'test' + + email2 = 'george@example.org' + + # Disable first time registering + os.environ['APP_ALLOW_FIRST_TIME_REGISTER'] = '' + + # Fails with improper env set + response = with_client.post( + '/token', + data={'username': email2, 'password': password}, + ) + assert response.status_code == 403, response.text + + # Enable first time registering + os.environ['APP_ALLOW_FIRST_TIME_REGISTER'] = 'True' + + # Test non-user credentials + response = with_client.post( + '/token', + data={'username': email, 'password': password}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data['access_token'] + assert data['token_type'] == 'bearer' + + # Test second non-user credentials + response = with_client.post( + '/token', + data={'username': email2, 'password': password}, + ) + assert response.status_code == 403, response.text + class TestFXA: def test_fxa_login(self, with_client): diff --git a/frontend/src/components/FTUE/SetupSchedule.vue b/frontend/src/components/FTUE/SetupSchedule.vue index 1dfc36b9..84e165c0 100644 --- a/frontend/src/components/FTUE/SetupSchedule.vue +++ b/frontend/src/components/FTUE/SetupSchedule.vue @@ -98,6 +98,7 @@ const onSubmit = async () => { farthest_booking: 20160, start_date: dj().format(DateFormatStrings.QalendarFullDay), details: schedule.value?.details ?? '', + timezone: user.data.timezone, }; const data = schedules.value.length > 0 diff --git a/frontend/src/components/ScheduleCreation.vue b/frontend/src/components/ScheduleCreation.vue index 58c95242..4bef0f84 100644 --- a/frontend/src/components/ScheduleCreation.vue +++ b/frontend/src/components/ScheduleCreation.vue @@ -278,7 +278,7 @@ const savingInProgress = ref(false); const saveSchedule = async (withConfirmation = true) => { savingInProgress.value = true; // build data object for post request - const obj = { ...scheduleInput.value }; + const obj = { ...scheduleInput.value, timezone: user.data.timezone }; // convert local input times to utc times obj.start_time = dj(`${dj().format('YYYY-MM-DD')}T${obj.start_time}:00`) diff --git a/frontend/src/models.ts b/frontend/src/models.ts index 0c790d1c..2eff13e6 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -303,7 +303,7 @@ export type FormExceptionDetail = { status: number } export type PydanticException = { - detail?: FormExceptionDetail|PydanticExceptionDetail[]; + detail?: string|FormExceptionDetail|PydanticExceptionDetail[]; } export type Exception = { status_code?: number; diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 7d9096e4..72b30428 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -62,13 +62,15 @@ const handleFormError = (errObj: PydanticException) => { const { detail } = errObj; const fields = formRef.value.elements; - if (Array.isArray(detail)) { + if (Array.isArray(detail)) { // Pydantic errors detail.forEach((err) => { const name = err?.loc[1]; if (name) { fields[name].setCustomValidity(err.ctx.reason); } }); + } else if (typeof detail === 'string') { // HttpException errors are just strings + loginError.value = detail; } else { loginError.value = detail.message; } @@ -134,6 +136,7 @@ const login = async () => { if (error?.value) { // Handle error + handleFormError(canLogin.value as PydanticException); isLoading.value = false; return; @@ -179,7 +182,7 @@ const login = async () => { const { error }: Error = await user.login(call, email.value, password.value); if (error) { - loginError.value = error as string; + handleFormError(error as PydanticException); isLoading.value = false; return; } @@ -233,7 +236,7 @@ const onEnter = () => {
{{ t('login.form.email') }} - {{ t('label.password') }} + {{ t('label.password') }} {{ t('label.inviteCode') }}