From 4bc31efd06311e8e2c9ffcf42502b3419eac546d Mon Sep 17 00:00:00 2001 From: Melissa Autumn Date: Fri, 10 May 2024 10:15:51 -0700 Subject: [PATCH] wip --- backend/src/appointment/database/models.py | 6 +- backend/src/appointment/database/schemas.py | 32 ++- backend/src/appointment/dependencies/auth.py | 8 +- backend/src/appointment/routes/auth.py | 10 +- backend/src/appointment/routes/invite.py | 9 +- backend/src/appointment/routes/subscriber.py | 5 +- backend/test/integration/test_invite.py | 57 ++++++ frontend/src/components/DataTable.vue | 128 ++++++++++++ frontend/src/definitions.js | 19 ++ frontend/src/locales/en.json | 1 + frontend/src/views/SubscriberPanelView.vue | 194 +++++++++++-------- 11 files changed, 369 insertions(+), 100 deletions(-) create mode 100644 backend/test/integration/test_invite.py create mode 100644 frontend/src/components/DataTable.vue 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/schemas.py b/backend/src/appointment/database/schemas.py index 18b5588d2..4bc542bfd 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,11 @@ class Config: from_attributes = True -""" INVITE model schemas -""" - +class SubscriberAdminOut(Subscriber): + invite: Invite | None = None -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 +365,9 @@ class Login(BaseModel): class TokenData(BaseModel): username: str + + +"""Invite""" + +class SendInviteEmailIn(BaseModel): + email: str diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index b1d3b99c9..885e329ec 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -60,7 +60,13 @@ def get_admin_subscriber( ): """Retrieve the subscriber and check if they're an admin""" # check admin allow list - admin_emails = os.getenv("APP_ADMIN_ALLOW_LIST", '').split(',') + 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() diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index 1be817289..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 @@ -240,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 dfa097071..521e5cd37 100644 --- a/backend/src/appointment/routes/invite.py +++ b/backend/src/appointment/routes/invite.py @@ -1,9 +1,12 @@ -from fastapi import APIRouter, Depends, BackgroundTasks +from typing import Annotated + +from fastapi import APIRouter, Depends, BackgroundTasks, Request, Body from sqlalchemy.orm import Session from ..database import repo, schemas, models from ..database.models import Subscriber +from ..database.schemas import SendInviteEmailIn from ..dependencies.auth import get_admin_subscriber from ..dependencies.database import get_db @@ -52,13 +55,15 @@ def revoke_invite_code(code: str, db: Session = Depends(get_db), admin: Subscrib @router.post("/send", response_model=schemas.Invite) def send_invite_email( + data: SendInviteEmailIn, background_tasks: BackgroundTasks, - email: str, 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 invite_code = repo.invite.generate_codes(db, 1)[0] subscriber = repo.subscriber.create(db, schemas.SubscriberBase( email=email, diff --git a/backend/src/appointment/routes/subscriber.py b/backend/src/appointment/routes/subscriber.py index 3cbaee8f6..8340d81be 100644 --- a/backend/src/appointment/routes/subscriber.py +++ b/backend/src/appointment/routes/subscriber.py @@ -13,10 +13,11 @@ router = APIRouter() -@router.get('/', response_model=list[schemas.Subscriber]) +@router.get('/', response_model=list[schemas.SubscriberAdminOut]) def get_all_subscriber(db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): """List all existing invites, needs admin permissions""" - return db.query(models.Subscriber).all() + response = db.query(models.Subscriber).all() + return response @router.put("/disable/{email}") 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/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue new file mode 100644 index 000000000..ff92b7b14 --- /dev/null +++ b/frontend/src/components/DataTable.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/src/definitions.js b/frontend/src/definitions.js index f220a724f..9fb5a26fe 100644 --- a/frontend/src/definitions.js +++ b/frontend/src/definitions.js @@ -243,6 +243,23 @@ export const qalendarSlotDurations = { */ export const loginRedirectKey = 'loginRedirect'; +/** + * Data types for table row items + * @enum + * @readonly + */ +export const tableDataType = { + text: 1, + link: 2, + button: 3, +}; + +export const tableDataButtonType = { + primary: 1, + secondary: 2, + caution: 3, +}; + export default { subscriberLevels, appointmentState, @@ -259,4 +276,6 @@ export default { meetingLinkProviderType, dateFormatStrings, qalendarSlotDurations, + tableDataType, + tableDataButtonType, }; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a83be434b..61dbfeaa5 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -7,6 +7,7 @@ "actionNeeded": "Action needed", "authenticationRequired": "Sorry, this page requires you to be logged in.", "credentialsIncomplete": "Please provide login credentials.", + "dataSourceIsEmpty": "No {name} could be found.", "generalBookingError": "Sorry, there was a problem retrieving the schedule details. Please try again later.", "googleRefreshError": "Error connecting with Google API, please re-connect.", "loginMethodNotSupported": "Login method not supported. Please try again.", diff --git a/frontend/src/views/SubscriberPanelView.vue b/frontend/src/views/SubscriberPanelView.vue index faf89f632..b0ed51814 100644 --- a/frontend/src/views/SubscriberPanelView.vue +++ b/frontend/src/views/SubscriberPanelView.vue @@ -1,102 +1,109 @@