diff --git a/backend/.env.example b/backend/.env.example index 873912f28..da11119f5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,10 @@ # Logging level: DEBUG|INFO|WARNING|ERROR|CRITICAL LOG_LEVEL=ERROR LOG_USE_STREAM=1 +# Possible values: prod, dev +APP_ENV=dev +# List of comma separated admin usernames. USE WITH CAUTION! Those can do serious damage to the data. +APP_ADMIN_ALLOW_LIST= # -- FRONTEND -- FRONTEND_URL=http://localhost:8080 @@ -20,12 +24,10 @@ DB_SECRET= SESSION_SECRET= # -- MAIL -- - # Service email for emails on behalf of Thunderbird Appointment SERVICE_EMAIL=no-reply@appointment.day # Email address for contact or support requests. If the value is empty the support form will error out SUPPORT_EMAIL= - # Connection security: SSL|STARTTLS|NONE SMTP_SECURITY=NONE # Address and port of the SMTP server @@ -34,6 +36,8 @@ SMTP_PORT=8050 # SMTP user credentials SMTP_USER= SMTP_PASS= +# Authorized email address for sending emails, leave empty to default to organizer +SMTP_SENDER= # -- TIERS -- # Max number of calendars to be simultanously connected for members of the basic tier @@ -43,6 +47,22 @@ TIER_PLUS_CALENDAR_LIMIT=5 # Max number of calendars to be simultanously connected for members of the pro tier TIER_PRO_CALENDAR_LIMIT=10 +# -- GENERAL AUTHENTICATION -- +# Possible values: password, fxa +AUTH_SCHEME=password + +# For password auth only! +JWT_SECRET= +JWT_ALGO=HS256 +JWT_EXPIRE_IN_MINS=10000 + +# -- FIREFOX AUTH -- +FXA_OPEN_ID_CONFIG= +FXA_CLIENT_ID= +FXA_SECRET= +FXA_CALLBACK= +FXA_ALLOW_LIST= + # -- GOOGLE AUTH -- GOOGLE_AUTH_CLIENT_ID= GOOGLE_AUTH_SECRET= @@ -58,23 +78,9 @@ ZOOM_AUTH_CALLBACK=http://localhost:8090/zoom/callback # -- SIGNED URL SECRET -- # Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`) SIGNED_SECRET= + # If empty, sentry will be disabled SENTRY_DSN= -# Possible values: prod, dev -APP_ENV=dev -# Possible values: password, fxa -AUTH_SCHEME=password - -FXA_OPEN_ID_CONFIG= -FXA_CLIENT_ID= -FXA_SECRET= -FXA_CALLBACK= -FXA_ALLOW_LIST= - -# For password auth only! -JWT_SECRET= -JWT_ALGO=HS256 -JWT_EXPIRE_IN_MINS=10000 # -- TESTING -- AUTH0_TEST_USER= diff --git a/backend/.env.test b/backend/.env.test index 4c2ce96e0..a0c17077f 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -20,7 +20,6 @@ DB_SECRET=db-secret-pls-ignore SESSION_SECRET=session-secret-pls-ignore # -- MAIL -- - # Service email for emails on behalf of Thunderbird Appointment SERVICE_EMAIL=no-reply@appointment.day # Email address for contact or support requests @@ -64,6 +63,7 @@ SIGNED_SECRET=test-secret-pls-ignore SENTRY_DSN= # Possible values: prod, dev, test APP_ENV=test +# Possible values: password, fxa AUTH_SCHEME=password FXA_OPEN_ID_CONFIG= diff --git a/backend/src/appointment/controller/apis/fxa_client.py b/backend/src/appointment/controller/apis/fxa_client.py index affb9b33f..173b84839 100644 --- a/backend/src/appointment/controller/apis/fxa_client.py +++ b/backend/src/appointment/controller/apis/fxa_client.py @@ -73,8 +73,14 @@ def setup(self, subscriber_id=None, token=None): token=token, token_updater=self.token_saver) - def is_in_allow_list(self, email: str): + def is_in_allow_list(self, db, email: str): """Check this email against our allow list""" + + # Allow existing subscribers to login even if they're not on an allow-list + subscriber = repo.subscriber.get_by_email(db, email) + if subscriber: + return True + allow_list = os.getenv('FXA_ALLOW_LIST') # If we have no allow list, then we allow everyone if not allow_list or allow_list == '': @@ -82,8 +88,8 @@ def is_in_allow_list(self, email: str): return email.endswith(tuple(allow_list.split(','))) - def get_redirect_url(self, state, email): - if not self.is_in_allow_list(email): + def get_redirect_url(self, db, state, email): + if not self.is_in_allow_list(db, email): raise NotInAllowListException() utm_campaign = f"{self.ENTRYPOINT}_{os.getenv('APP_ENV')}" diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index 5364d45c7..a0b07419d 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -259,3 +259,19 @@ def text(self): def html(self): return get_template("support.jinja2").render(requestee=self.requestee, topic=self.topic, details=self.details) + + +class InviteAccountMail(Mailer): + def __init__(self, *args, **kwargs): + default_kwargs = { + "subject": l10n('new-account-mail-subject') + } + super(InviteAccountMail, self).__init__(*args, **default_kwargs, **kwargs) + + def text(self): + return l10n('new-account-mail-plain', { + 'homepage_url': os.getenv('FRONTEND_URL'), + }) + + def html(self): + return get_template("new_account.jinja2").render(homepage_url=os.getenv('FRONTEND_URL')) diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 2c58241cd..9d20f23de 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -11,7 +11,7 @@ from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time from sqlalchemy_utils import StringEncryptedType, ChoiceType, UUIDType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine -from sqlalchemy.orm import relationship, as_declarative, declared_attr +from sqlalchemy.orm import relationship, as_declarative, declared_attr, Mapped from sqlalchemy.sql import func @@ -116,7 +116,7 @@ class Subscriber(Base): 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") - invite: "Invite" = relationship("Invite", back_populates="subscriber") + invite: Mapped["Invite"] = relationship("Invite", back_populates="subscriber", uselist=False) def get_external_connection(self, type: ExternalConnectionType) -> 'ExternalConnections': """Retrieves the first found external connection by type or returns None if not found""" @@ -288,7 +288,7 @@ class Invite(Base): code = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) status = Column(Enum(InviteStatus), index=True) - subscriber = relationship("Subscriber", back_populates="invite") + subscriber: Mapped["Subscriber"] = relationship("Subscriber", back_populates="invite", single_parent=True) @property def is_used(self) -> bool: diff --git a/backend/src/appointment/database/repo/subscriber.py b/backend/src/appointment/database/repo/subscriber.py index 8d8fba1f8..bc8d4a5e5 100644 --- a/backend/src/appointment/database/repo/subscriber.py +++ b/backend/src/appointment/database/repo/subscriber.py @@ -15,7 +15,7 @@ def get(db: Session, subscriber_id: int) -> models.Subscriber | None: return db.get(models.Subscriber, subscriber_id) -def get_by_email(db: Session, email: str): +def get_by_email(db: Session, email: str) -> models.Subscriber | None: """retrieve subscriber by email""" return db.query(models.Subscriber).filter(models.Subscriber.email == email).first() diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 18b5588d2..420950ede 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -224,6 +224,19 @@ class CalendarOut(CalendarBase): id: int +""" INVITE model schemas +""" + + +class Invite(BaseModel): + subscriber_id: int | None = None + code: str + status: InviteStatus = InviteStatus.active + time_created: datetime | None = None + time_updated: datetime | None = None + + + """ SUBSCRIBER model schemas """ @@ -253,16 +266,12 @@ class Config: from_attributes = True -""" INVITE model schemas -""" - +class SubscriberAdminOut(Subscriber): + invite: Invite | None = None + time_created: datetime -class Invite(BaseModel): - subscriber_id: int | None = None - code: str - status: InviteStatus = InviteStatus.active - time_created: datetime | None = None - time_updated: datetime | None = None + class Config: + from_attributes = True """ other schemas used for requests or data migration @@ -357,3 +366,10 @@ class Login(BaseModel): class TokenData(BaseModel): username: str + + +"""Invite""" + + +class SendInviteEmailIn(BaseModel): + email: str = Field(title="Email", min_length=1) diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index ccece129f..885e329ec 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -7,10 +7,10 @@ from sqlalchemy.orm import Session -from ..database import repo, schemas +from ..database import repo, schemas, models from ..dependencies.database import get_db from ..exceptions import validation -from ..exceptions.validation import InvalidTokenException +from ..exceptions.validation import InvalidTokenException, InvalidPermissionLevelException oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) @@ -55,6 +55,24 @@ def get_subscriber( return user +def get_admin_subscriber( + user: models.Subscriber = Depends(get_subscriber), +): + """Retrieve the subscriber and check if they're an admin""" + # check admin allow list + admin_emails = os.getenv("APP_ADMIN_ALLOW_LIST") + + # Raise an error if we don't have any admin emails specified + if not admin_emails: + raise InvalidPermissionLevelException() + + admin_emails = admin_emails.split(',') + if not any([user.email.endswith(allowed_email) for allowed_email in admin_emails]): + raise InvalidPermissionLevelException() + + return user + + def get_subscriber_from_signed_url( url: str = Body(..., embed=True), db: Session = Depends(get_db), diff --git a/backend/src/appointment/exceptions/validation.py b/backend/src/appointment/exceptions/validation.py index e939b2977..74a6bf066 100644 --- a/backend/src/appointment/exceptions/validation.py +++ b/backend/src/appointment/exceptions/validation.py @@ -20,6 +20,15 @@ def get_msg(self): return l10n('unknown-error') +class InvalidPermissionLevelException(APIException): + """Raise when the subscribers permission level is too low for the action""" + id_code = 'INVALID_PERMISSION_LEVEL' + status_code = 401 + + def get_msg(self): + return l10n('protected-route-fail') + + class InvalidTokenException(APIException): """Raise when the subscriber could not be parsed from the auth token""" id_code = 'INVALID_TOKEN' @@ -39,14 +48,13 @@ def get_msg(self): class SubscriberNotFoundException(APIException): - """Raise when the calendar is not found during route validation""" + """Raise when the subscriber is not found during route validation""" id_code = 'SUBSCRIBER_NOT_FOUND' status_code = 404 def get_msg(self): return l10n('subscriber-not-found') - class CalendarNotFoundException(APIException): """Raise when the calendar is not found during route validation""" id_code = 'CALENDAR_NOT_FOUND' @@ -187,3 +195,21 @@ class InviteCodeNotAvailableException(APIException): def get_msg(self): return l10n('invite-code-not-valid') + + +class CreateSubscriberFailedException(APIException): + """Raise when a subscriber failed to be created""" + id_code = 'CREATE_SUBSCRIBER_FAILED' + status_code = 400 + + def get_msg(self): + return l10n('failed-to-create-subscriber') + + +class CreateSubscriberAlreadyExistsException(APIException): + """Raise when a subscriber failed to be created""" + id_code = 'CREATE_SUBSCRIBER_ALREADY_EXISTS' + status_code = 400 + + def get_msg(self): + return l10n('subscriber-already-exists') diff --git a/backend/src/appointment/l10n/de/main.ftl b/backend/src/appointment/l10n/de/main.ftl index 25f3adaf7..1951e6758 100644 --- a/backend/src/appointment/l10n/de/main.ftl +++ b/backend/src/appointment/l10n/de/main.ftl @@ -40,6 +40,9 @@ remote-calendar-connection-error = Der angebundene Kalender konnte nicht erreich event-could-not-be-accepted = Es ist ein Fehler bei der Annahme der Buchungsdaten aufgetreten. Bitte später noch einmal versuchen. +failed-to-create-subscriber = Es gab einen Fehler beim Anlegen der Person. Bitte später erneut versuchen. +subscriber-already-exists = Eine Person mit dieser E-Mail-Adresse existiert bereits. + ## Authentication Exceptions email-mismatch = E-Mail-Adresse stimmen nicht überein. diff --git a/backend/src/appointment/l10n/en/email.ftl b/backend/src/appointment/l10n/en/email.ftl index f25844fb7..4f2e0ccf0 100644 --- a/backend/src/appointment/l10n/en/email.ftl +++ b/backend/src/appointment/l10n/en/email.ftl @@ -103,3 +103,14 @@ support-mail-plain = { $requestee_name } ({ $requestee_email }) sent the followi Topic: { $topic } Details: { $details } {-brand-footer} + +## New/Invited Account Email +new-account-mail-subject = You've been invited to Thunderbird Appointment +new-account-mail-action = Continue to Thunderbird Appointment +new-account-mail-html-heading = You've been invited to Thunderbird Appointment. Login with this email address to continue. +# Variables: +# $homepage_url (String) - URL to Thunderbird Appointment +new-account-mail-plain = You've been invited to Thunderbird Appointment. + Login with this email address to continue. + { $homepage_url } + {-brand-footer} diff --git a/backend/src/appointment/l10n/en/main.ftl b/backend/src/appointment/l10n/en/main.ftl index a0967a24c..d5bf4b1b5 100644 --- a/backend/src/appointment/l10n/en/main.ftl +++ b/backend/src/appointment/l10n/en/main.ftl @@ -40,6 +40,9 @@ remote-calendar-connection-error = The remote calendar could not be reached. Ple event-could-not-be-accepted = There was an error accepting the booking details. Please try again later. +failed-to-create-subscriber = There was an error creating the subscriber. Please try again later. +subscriber-already-exists = A subscriber with this email address already exists. + ## Authentication Exceptions email-mismatch = Email mismatch. diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index 53a22b5d7..362cec2de 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -121,6 +121,7 @@ def server(): from .routes import google from .routes import schedule from .routes import invite + from .routes import subscriber from .routes import zoom from .routes import webhooks @@ -189,6 +190,7 @@ async def catch_google_refresh_errors(request, exc): app.include_router(google.router, prefix="/google") app.include_router(schedule.router, prefix="/schedule") app.include_router(invite.router, prefix="/invite") + app.include_router(subscriber.router, prefix="/subscriber") app.include_router(webhooks.router, prefix="/webhooks") if os.getenv("ZOOM_API_ENABLED"): app.include_router(zoom.router, prefix="/zoom") diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 18bc46f03..ee599de23 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -1,5 +1,6 @@ import json import os +import time from datetime import timedelta, datetime, UTC from secrets import token_urlsafe from typing import Annotated @@ -18,7 +19,7 @@ from ..database.models import Subscriber, ExternalConnectionType from ..dependencies.database import get_db -from ..dependencies.auth import get_subscriber +from ..dependencies.auth import get_subscriber, get_admin_subscriber from ..controller import auth from ..controller.apis.fxa_client import FxaClient @@ -44,7 +45,10 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): @router.get("/fxa_login") -def fxa_login(request: Request, email: str, timezone: str | None = None, +def fxa_login(request: Request, + email: str, + timezone: str | None = None, + db: Session = Depends(get_db), fxa_client: FxaClient = Depends(get_fxa_client)): """Request an authorization url from fxa""" if os.getenv('AUTH_SCHEME') != 'fxa': @@ -53,7 +57,7 @@ def fxa_login(request: Request, email: str, timezone: str | None = None, fxa_client.setup() try: - url, state = fxa_client.get_redirect_url(token_urlsafe(32), email) + url, state = fxa_client.get_redirect_url(db, token_urlsafe(32), email) except NotInAllowListException: raise HTTPException(status_code=403, detail='Your email is not in the allow list') @@ -237,6 +241,13 @@ def me( timezone=subscriber.timezone, avatar_url=subscriber.avatar_url ) + +@router.post("/permission-check") +def permission_check(_: Subscriber = Depends(get_admin_subscriber)): + """Checks if they have admin permissions""" + return True + + # @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""" diff --git a/backend/src/appointment/routes/invite.py b/backend/src/appointment/routes/invite.py index 29eef5b8e..2dae4faf7 100644 --- a/backend/src/appointment/routes/invite.py +++ b/backend/src/appointment/routes/invite.py @@ -1,21 +1,32 @@ +import time +from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, BackgroundTasks, Request, Body from sqlalchemy.orm import Session from ..database import repo, schemas, models -from ..dependencies.auth import get_subscriber, get_subscriber_from_signed_url +from ..database.models import Subscriber +from ..database.schemas import SendInviteEmailIn +from ..dependencies.auth import get_admin_subscriber from ..dependencies.database import get_db from ..exceptions import validation +from ..exceptions.validation import CreateSubscriberFailedException, CreateSubscriberAlreadyExistsException +from ..tasks.emails import send_invite_account_email router = APIRouter() +@router.get('/', response_model=list[schemas.Invite]) +def get_all_invites(db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): + """List all existing invites, needs admin permissions""" + return db.query(models.Invite).all() + + @router.post("/generate/{n}", response_model=list[schemas.Invite]) -def generate_invite_codes(n: int, db: Session = Depends(get_db)): - raise NotImplementedError - """endpoint to generate n invite codes""" +def generate_invite_codes(n: int, db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): + """endpoint to generate n invite codes, needs admin permissions""" return repo.invite.generate_codes(db, n) @@ -35,11 +46,44 @@ def use_invite_code(code: str, db: Session = Depends(get_db)): @router.put("/revoke/{code}") -def revoke_invite_code(code: str, db: Session = Depends(get_db)): - raise NotImplementedError - """endpoint to revoke a given invite code and mark in unavailable""" +def revoke_invite_code(code: str, db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): + """endpoint to revoke a given invite code and mark in unavailable, needs admin permissions""" if not repo.invite.code_exists(db, code): raise validation.InviteCodeNotFoundException() if not repo.invite.code_is_available(db, code): raise validation.InviteCodeNotAvailableException() return repo.invite.revoke_code(db, code) + + +@router.post("/send", response_model=schemas.Invite) +def send_invite_email( + data: SendInviteEmailIn, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + # Note admin must be here to for permission reasons + _admin: Subscriber = Depends(get_admin_subscriber) +): + """With a given email address, generate a subscriber and email them, welcoming them to Thunderbird Appointment.""" + email = data.email + + lookup = repo.subscriber.get_by_email(db, email) + + if lookup: + raise CreateSubscriberAlreadyExistsException() + + invite_code = repo.invite.generate_codes(db, 1)[0] + subscriber = repo.subscriber.create(db, schemas.SubscriberBase( + email=email, + username=email, + )) + + if not subscriber: + raise CreateSubscriberFailedException() + + invite_code.subscriber_id = subscriber.id + db.add(invite_code) + db.commit() + + background_tasks.add_task(send_invite_account_email, to=email) + + return invite_code diff --git a/backend/src/appointment/routes/subscriber.py b/backend/src/appointment/routes/subscriber.py new file mode 100644 index 000000000..105ac1086 --- /dev/null +++ b/backend/src/appointment/routes/subscriber.py @@ -0,0 +1,35 @@ +import time + +from fastapi import APIRouter, Depends + +from sqlalchemy.orm import Session + +from ..database import repo, schemas, models +from ..database.models import Subscriber +from ..dependencies.auth import get_admin_subscriber +from ..dependencies.database import get_db + +from ..exceptions import validation + +router = APIRouter() + + +@router.get('/', response_model=list[schemas.SubscriberAdminOut]) +def get_all_subscriber(db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)): + """List all existing invites, needs admin permissions""" + response = db.query(models.Subscriber).all() + return response + + +@router.put("/disable/{email}") +def disable_subscriber(email: str, db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)): + """endpoint to disable a subscriber by email, needs admin permissions""" + # TODO: Add status to subscriber, and disable it instead. + raise NotImplementedError + + subscriber = repo.subscriber.get_by_email(db, email) + if not subscriber: + raise validation.SubscriberNotFoundException() + # TODO: CAUTION! This actually deletes the subscriber. We might want to only disable them. + # This needs an active flag on the subscribers model. + return repo.subscriber.delete(db, subscriber) diff --git a/backend/src/appointment/secrets.py b/backend/src/appointment/secrets.py index 97acd992c..0da098bc1 100644 --- a/backend/src/appointment/secrets.py +++ b/backend/src/appointment/secrets.py @@ -64,3 +64,4 @@ def normalize_secrets(): os.environ['FXA_SECRET'] = secrets.get('secret') os.environ['FXA_CALLBACK'] = secrets.get('callback_url') os.environ['FXA_ALLOW_LIST'] = secrets.get('allow_list') + os.environ['APP_ADMIN_ALLOW_LIST'] = secrets.get('admin_list') diff --git a/backend/src/appointment/tasks/emails.py b/backend/src/appointment/tasks/emails.py index 4f5eb1fc4..326119fc9 100644 --- a/backend/src/appointment/tasks/emails.py +++ b/backend/src/appointment/tasks/emails.py @@ -1,5 +1,5 @@ from appointment.controller.mailer import PendingRequestMail, ConfirmationMail, InvitationMail, ZoomMeetingFailedMail, \ - RejectionMail, SupportRequestMail + RejectionMail, SupportRequestMail, InviteAccountMail def send_invite_email(to, attachment): @@ -49,3 +49,8 @@ def send_support_email(requestee, topic, details): details=details, ) mail.send() + + +def send_invite_account_email(to): + mail = InviteAccountMail(to=to) + mail.send() diff --git a/backend/src/appointment/templates/email/new_account.jinja2 b/backend/src/appointment/templates/email/new_account.jinja2 new file mode 100644 index 000000000..5511eee7a --- /dev/null +++ b/backend/src/appointment/templates/email/new_account.jinja2 @@ -0,0 +1,21 @@ + +
++ {{ l10n('new-account-mail-html-heading') }} +
+ + {% include 'includes/footer.jinja2' %} + + diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 61405d2af..78e63d45f 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -87,7 +87,7 @@ def setup(self, subscriber_id=None, token=None): pass @staticmethod - def get_redirect_url(self, state, email): + def get_redirect_url(self, db, state, email): return FXA_CLIENT_PATCH.get('authorization_url'), state @staticmethod diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index 73492b1d8..5efbf3905 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -1,7 +1,7 @@ import os from appointment.l10n import l10n -from defines import FXA_CLIENT_PATCH, TEST_USER_ID +from defines import FXA_CLIENT_PATCH, auth_headers from appointment.database import repo, models @@ -91,7 +91,8 @@ def test_fxa_callback(self, with_db, with_client, monkeypatch): assert fxa assert fxa.type_id == FXA_CLIENT_PATCH.get('external_connection_type_id') - def test_fxa_callback_with_mismatch_uid(self, with_db, with_client, monkeypatch, make_external_connections, make_basic_subscriber, with_l10n): + def test_fxa_callback_with_mismatch_uid(self, with_db, with_client, monkeypatch, make_external_connections, + make_basic_subscriber, with_l10n): """Test that our fxa callback will throw an invalid-credentials error if the incoming fxa uid doesn't match any existing ones.""" os.environ['AUTH_SCHEME'] = 'fxa' @@ -121,3 +122,31 @@ def test_fxa_callback_with_mismatch_uid(self, with_db, with_client, monkeypatch, assert response.status_code == 403, response.text # This will just key match due to the lack of context. assert response.json().get('detail') == l10n('invalid-credentials') + + def test_permission_check_with_no_admin_email(self, with_client): + os.environ['APP_ADMIN_ALLOW_LIST'] = '' + + response = with_client.post('/permission-check', + headers=auth_headers) + assert response.status_code == 401, response.text + + def test_permission_check_with_wrong_admin_email(self, with_client): + os.environ['APP_ADMIN_ALLOW_LIST'] = '@notexample.org' + + response = with_client.post('/permission-check', + headers=auth_headers) + assert response.status_code == 401, response.text + + def test_permission_check_with_correct_admin_email(self, with_client): + os.environ['APP_ADMIN_ALLOW_LIST'] = f"@{os.getenv('TEST_USER_EMAIL').split('@')[1]}" + + response = with_client.post('/permission-check', + headers=auth_headers) + assert response.status_code == 200, response.text + + def test_permission_check_with_correct_full_admin_email(self, with_client): + os.environ['APP_ADMIN_ALLOW_LIST'] = os.getenv('TEST_USER_EMAIL') + + response = with_client.post('/permission-check', + headers=auth_headers) + assert response.status_code == 200, response.text diff --git a/backend/test/integration/test_invite.py b/backend/test/integration/test_invite.py new file mode 100644 index 000000000..c994107ac --- /dev/null +++ b/backend/test/integration/test_invite.py @@ -0,0 +1,57 @@ +import os +from defines import auth_headers +from appointment.database import repo + + +class TestInvite: + def test_send_invite_email_requires_admin(self, with_db, with_client): + """Ensures send_invite_email requires an admin user""" + + os.environ['APP_ADMIN_ALLOW_LIST'] = '@notexample.org' + + response = with_client.post( + "/invite/send", + json={ + "email": "beatrice@ismycat.meow" + }, + headers=auth_headers, + ) + assert response.status_code == 401, response.text + + def test_send_invite_email_requires_at_least_one_admin_email(self, with_db, with_client): + """Ensures send_invite_email requires an admin user""" + + os.environ['APP_ADMIN_ALLOW_LIST'] = '' + + response = with_client.post( + "/invite/send", + json={ + "email": "beatrice@ismycat.meow" + }, + headers=auth_headers, + ) + assert response.status_code == 401, response.text + + def test_send_invite_email(self, with_db, with_client): + """Ensures send_invite_email requires an admin user""" + + os.environ['APP_ADMIN_ALLOW_LIST'] = '@example.org' + + invite_email = 'beatrice@ismycat.meow' + + with with_db() as db: + subscriber = repo.subscriber.get_by_email(db, invite_email) + assert subscriber is None + + response = with_client.post( + "/invite/send", + json={ + 'email': invite_email + }, + headers=auth_headers, + ) + assert response.status_code == 200, response.text + + with with_db() as db: + subscriber = repo.subscriber.get_by_email(db, invite_email) + assert subscriber is not None diff --git a/backend/test/unit/test_fxa_client.py b/backend/test/unit/test_fxa_client.py index 14792cbf2..704d2b405 100644 --- a/backend/test/unit/test_fxa_client.py +++ b/backend/test/unit/test_fxa_client.py @@ -4,36 +4,55 @@ class TestFxaClient: - def test_is_in_allow_list(self): - os.environ['FXA_ALLOW_LIST'] = '' + def test_is_in_allow_list(self, with_db): + with with_db() as db: + os.environ['FXA_ALLOW_LIST'] = '' - fxa_client = FxaClient(None, None, None) + fxa_client = FxaClient(None, None, None) - test_email = 'test@example.org' + test_email = 'cooltestguy@example.org' - assert fxa_client.is_in_allow_list(test_email) + assert fxa_client.is_in_allow_list(db, test_email) - # Domain is in allow list - os.environ['FXA_ALLOW_LIST'] = '@example.org' - assert fxa_client.is_in_allow_list(test_email) + # Domain is in allow list + os.environ['FXA_ALLOW_LIST'] = '@example.org' + assert fxa_client.is_in_allow_list(db, test_email) - # Email is in allow list - os.environ['FXA_ALLOW_LIST'] = test_email - assert fxa_client.is_in_allow_list(test_email) + # Email is in allow list + os.environ['FXA_ALLOW_LIST'] = test_email + assert fxa_client.is_in_allow_list(db, test_email) - # Domain is not in allow list - os.environ['FXA_ALLOW_LIST'] = '@example.com' - assert not fxa_client.is_in_allow_list(test_email) + # Domain is not in allow list + os.environ['FXA_ALLOW_LIST'] = '@example.com' + assert not fxa_client.is_in_allow_list(db, test_email) - # Domain is in allow list - os.environ['FXA_ALLOW_LIST'] = '@example.com,@example.org' - assert fxa_client.is_in_allow_list(test_email) + # Domain is in allow list + os.environ['FXA_ALLOW_LIST'] = '@example.com,@example.org' + assert fxa_client.is_in_allow_list(db, test_email) + + # Email is not allow list + os.environ['FXA_ALLOW_LIST'] = '@example.com,not-test@example.org' + assert not fxa_client.is_in_allow_list(db, test_email) + + # Email is not allow list + os.environ['FXA_ALLOW_LIST'] = '@example.com,not-test@example.org' + assert not fxa_client.is_in_allow_list(db, 'hello@example.com@bad.org') + + def test_allow_list_allows_subscriber(self, with_db, make_basic_subscriber): + with with_db() as db: + os.environ['FXA_ALLOW_LIST'] = '@abadexample.org' + + fxa_client = FxaClient(None, None, None) + + test_email = 'new-test@example.org' + + # They're not a user, and they're not in the allow list + assert not fxa_client.is_in_allow_list(db, test_email) + + make_basic_subscriber(email=test_email) + + # They're not in the allow list, but they are a user! + assert fxa_client.is_in_allow_list(db, test_email) - # Email is not allow list - os.environ['FXA_ALLOW_LIST'] = '@example.com,not-test@example.org' - assert not fxa_client.is_in_allow_list(test_email) - # Email is not allow list - os.environ['FXA_ALLOW_LIST'] = '@example.com,not-test@example.org' - assert not fxa_client.is_in_allow_list('hello@example.com@bad.org') diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 96eab96c1..8b0c8ae88 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -111,3 +111,29 @@ .v-leave-to { @apply opacity-0; } + +/* custom data table with wrapper */ +.data-table { + @apply rounded-xl w-full border py-2 border-gray-100 bg-white text-sm shadow-sm dark:border-gray-500 dark:bg-gray-700 mb-4 ml-auto mr-0; + + table { + @apply w-full table-auto border-collapse bg-white text-sm shadow-sm dark:bg-gray-600; + } + + thead, tfoot { + @apply border-gray-200 bg-gray-100 dark:border-gray-500 dark:bg-gray-700 text-gray-600 dark:text-gray-300; + } + + th { + @apply px-4 text-left font-semibold border-gray-200 dark:border-gray-500; + } + thead th { + @apply pb-4 pt-2; + } + tfoot th { + @apply pb-2 pt-4; + } + td { + @apply border-y border-gray-200 p-4 dark:border-gray-500; + } +} diff --git a/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue new file mode 100644 index 000000000..0438e1db1 --- /dev/null +++ b/frontend/src/components/DataTable.vue @@ -0,0 +1,162 @@ + ++ + | +{{ column.name }} | +
---|---|
+ onFieldSelect(evt, datum)" /> + | +
+
+ {{ fieldData.value }}
+
+
+ {{ fieldData.value }}
+
+
+ |
+
+
+
+ |
+ |
{{ t('error.dataSourceIsEmpty', {name: dataName}) }} | +|
+ |
+