diff --git a/backend/src/appointment/routes/auth.py b/backend/src/appointment/routes/auth.py index ee599de23..e690e454d 100644 --- a/backend/src/appointment/routes/auth.py +++ b/backend/src/appointment/routes/auth.py @@ -24,6 +24,7 @@ from ..controller import auth from ..controller.apis.fxa_client import FxaClient from ..dependencies.fxa import get_fxa_client +from ..exceptions import validation from ..exceptions.fxa_api import NotInAllowListException from ..l10n import l10n @@ -48,6 +49,7 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): def fxa_login(request: Request, email: str, timezone: str | None = None, + invite_code: str | None = None, db: Session = Depends(get_db), fxa_client: FxaClient = Depends(get_fxa_client)): """Request an authorization url from fxa""" @@ -64,6 +66,7 @@ def fxa_login(request: Request, request.session['fxa_state'] = state request.session['fxa_user_email'] = email request.session['fxa_user_timezone'] = timezone + request.session['fxa_user_invite_code'] = invite_code return { 'url': url @@ -99,11 +102,14 @@ def fxa_callback( email = request.session['fxa_user_email'] # We only use timezone during subscriber creation, or if their timezone is None timezone = request.session['fxa_user_timezone'] + invite_code = request.session.get('fxa_user_invite_code') # Clear session keys request.session.pop('fxa_state') request.session.pop('fxa_user_email') request.session.pop('fxa_user_timezone') + if invite_code: + request.session.pop('fxa_user_invite_code') fxa_client.setup() @@ -123,11 +129,26 @@ def fxa_callback( new_subscriber_flow = not fxa_subscriber and not subscriber if new_subscriber_flow: + # Ensure the invite code exists and is available + # Use some inline-errors for now. We don't have a good error flow! + if not repo.invite.code_exists(db, invite_code): + raise HTTPException(404, l10n('invite-code-not-valid')) + if not repo.invite.code_is_available(db, invite_code): + raise HTTPException(403, l10n('invite-code-not-valid')) + subscriber = repo.subscriber.create(db, schemas.SubscriberBase( email=email, username=email, timezone=timezone, )) + + # Use the invite code after we've created the new subscriber + used = repo.invite.use_code(db, invite_code, subscriber.id) + + # This shouldn't happen, but just in case! + if not used: + repo.subscriber.delete(db, subscriber) + raise HTTPException(500, l10n('unknown-error')) elif not subscriber: subscriber = fxa_subscriber diff --git a/backend/src/appointment/routes/invite.py b/backend/src/appointment/routes/invite.py index 2dae4faf7..fce7d20ee 100644 --- a/backend/src/appointment/routes/invite.py +++ b/backend/src/appointment/routes/invite.py @@ -19,32 +19,17 @@ @router.get('/', response_model=list[schemas.Invite]) -def get_all_invites(db: Session = Depends(get_db), admin: Subscriber = Depends(get_admin_subscriber)): +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), admin: Subscriber = Depends(get_admin_subscriber)): +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) -@router.put("/redeem/{code}") -def use_invite_code(code: str, db: Session = Depends(get_db)): - raise NotImplementedError - - """endpoint to create a new subscriber and update the corresponding invite""" - if not repo.invite.code_exists(db, code): - raise validation.InviteCodeNotFoundException() - if not repo.invite.code_is_available(db, code): - raise validation.InviteCodeNotAvailableException() - # TODO: get email from admin panel - email = 'placeholder@mozilla.org' - subscriber = repo.subscriber.create(db, schemas.SubscriberBase(email=email, username=email)) - return repo.invite.use_code(db, code, subscriber.id) - - @router.put("/revoke/{code}") 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""" diff --git a/backend/test/conftest.py b/backend/test/conftest.py index 78e63d45f..03906fd38 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -20,6 +20,7 @@ from factory.schedule_factory import make_schedule # noqa: F401 from factory.slot_factory import make_appointment_slot # noqa: F401 from factory.subscriber_factory import make_subscriber, make_basic_subscriber, make_pro_subscriber # noqa: F401 +from factory.invite_factory import make_invite # Load our env load_dotenv(find_dotenv(".env.test")) diff --git a/backend/test/factory/invite_factory.py b/backend/test/factory/invite_factory.py new file mode 100644 index 000000000..6de1a2a75 --- /dev/null +++ b/backend/test/factory/invite_factory.py @@ -0,0 +1,19 @@ +import pytest +from faker import Faker +from appointment.database import models +from defines import FAKER_RANDOM_VALUE, factory_has_value + + +@pytest.fixture +def make_invite(with_db): + fake = Faker() + + def _make_invite(subscriber_id=None, code=FAKER_RANDOM_VALUE, status=models.InviteStatus.active) -> models.Invite: + with with_db() as db: + invite = models.Invite(subscriber_id=subscriber_id, status=status, code=code if factory_has_value(code) else fake.uuid4()) + db.add(invite) + db.commit() + db.refresh(invite) + return invite + + return _make_invite diff --git a/backend/test/integration/test_auth.py b/backend/test/integration/test_auth.py index 5efbf3905..c4aecb57c 100644 --- a/backend/test/integration/test_auth.py +++ b/backend/test/integration/test_auth.py @@ -59,16 +59,19 @@ def test_fxa_login(self, with_client): assert 'url' in data assert data.get('url') == FXA_CLIENT_PATCH.get('authorization_url') - def test_fxa_callback(self, with_db, with_client, monkeypatch): + def test_fxa_callback(self, with_db, with_client, monkeypatch, make_invite): """Test that our callback function correctly handles the session states, and creates a new subscriber""" os.environ['AUTH_SCHEME'] = 'fxa' state = 'a1234' + invite = make_invite() + monkeypatch.setattr('starlette.requests.HTTPConnection.session', { 'fxa_state': state, 'fxa_user_email': FXA_CLIENT_PATCH.get('subscriber_email'), - 'fxa_user_timezone': 'America/Vancouver' + 'fxa_user_timezone': 'America/Vancouver', + 'fxa_user_invite_code': invite.code, }) response = with_client.get( diff --git a/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue index 0438e1db1..9528386f3 100644 --- a/frontend/src/components/DataTable.vue +++ b/frontend/src/components/DataTable.vue @@ -13,7 +13,7 @@ @@ -37,13 +37,17 @@ {{ fieldData.value }} + + {{ fieldData.value }} + + {{ fieldData.value }} - {{ fieldData.value }} - {{ fieldData.value }} - {{ fieldData.value }} + {{ fieldData.value }} + {{ fieldData.value }} + {{ fieldData.value }} @@ -94,6 +98,7 @@ import { tableDataButtonType, tableDataType } from '@/definitions'; import PrimaryButton from '@/elements/PrimaryButton.vue'; import SecondaryButton from '@/elements/SecondaryButton.vue'; import CautionButton from '@/elements/CautionButton.vue'; +import TextButton from '@/elements/TextButton.vue'; import LoadingSpinner from '@/elements/LoadingSpinner.vue'; const props = defineProps({ @@ -122,13 +127,13 @@ const updatePage = (index) => { const columnSpan = computed(() => (columns.value.length + (allowMultiSelect.value ? 1 : 0))); const selectedFields = ref([]); -const mutableDataList = ref([]); +const mutableDataList = ref(null); /** * Returns either a filtered data list, or the original all nice and paginated */ const paginatedDataList = computed(() => { - if (mutableDataList?.value?.length) { + if (mutableDataList?.value !== null) { return mutableDataList.value.slice(currentPage.value * pageSize, (currentPage.value + 1) * pageSize); } if (dataList?.value?.length) { @@ -138,6 +143,16 @@ const paginatedDataList = computed(() => { return []; }); +const totalDataLength = computed(() => { + if (mutableDataList?.value !== null) { + return mutableDataList.value?.length ?? 0; + } + if (dataList?.value?.length) { + return dataList.value.length; + } + return 0; +}); + const onFieldSelect = (evt, fieldData) => { const isChecked = evt?.target?.checked; @@ -154,8 +169,9 @@ const onFieldSelect = (evt, fieldData) => { const onColumnFilter = (evt, filter) => { mutableDataList.value = filter.fn(evt.target.value, dataList.value); + console.log('Data list info: ', mutableDataList.value, ' vs ', dataList.value); if (mutableDataList.value === dataList.value) { - mutableDataList.value = []; + mutableDataList.value = null; } }; diff --git a/frontend/src/definitions.js b/frontend/src/definitions.js index 9fb5a26fe..d02b6c1f0 100644 --- a/frontend/src/definitions.js +++ b/frontend/src/definitions.js @@ -252,6 +252,7 @@ export const tableDataType = { text: 1, link: 2, button: 3, + code: 4, }; export const tableDataButtonType = { diff --git a/frontend/src/elements/ListPagination.vue b/frontend/src/elements/ListPagination.vue index 313fad212..2fd7f1573 100644 --- a/frontend/src/elements/ListPagination.vue +++ b/frontend/src/elements/ListPagination.vue @@ -1,7 +1,7 @@ @@ -39,19 +39,13 @@ const props = defineProps({ listLength: Number, // number of total items in the displayed list pageSize: Number, // number of items per page }); -const emit = defineEmits(['update']) +const emit = defineEmits(['update']); const currentPage = ref(0); // index of active page -const isFirstPage = computed(() => { - return currentPage.value <= 0; -}); -const pageCount = computed(() => { - return Math.ceil(props.listLength/props.pageSize); -}); -const isLastPage = computed(() => { - return currentPage.value >= pageCount.value-1; -}); +const isFirstPage = computed(() => currentPage.value <= 0); +const pageCount = computed(() => Math.ceil(props.listLength / props.pageSize) || 1); +const isLastPage = computed(() => currentPage.value >= pageCount.value - 1); const prev = () => { if (!isFirstPage.value) { @@ -70,23 +64,15 @@ const goto = (index) => { emit('update', currentPage.value); }; -const showPageItem = (p) => { - return pageCount.value < 6 || p == 1 || p == 2 || isVisibleInnerPage(p) || p == pageCount.value-1; -}; -const showFirstEllipsis = (p) => { - return pageCount.value >= 6 && currentPage.value > 2 && p == 2; -}; -const showPageItemLink = (p) => { - return pageCount.value < 6 || p == 1 || isVisibleInnerPage(p); -}; -const showLastEllipsis = (p) => { - return pageCount.value >= 6 && currentPage.value < pageCount.value-3 && p == pageCount.value-1; -}; +const showPageItem = (p) => pageCount.value < 6 || p == 1 || p == 2 || isVisibleInnerPage(p) || p == pageCount.value - 1; +const showFirstEllipsis = (p) => pageCount.value >= 6 && currentPage.value > 2 && p == 2; +const showPageItemLink = (p) => pageCount.value < 6 || p == 1 || isVisibleInnerPage(p); +const showLastEllipsis = (p) => pageCount.value >= 6 && currentPage.value < pageCount.value - 3 && p == pageCount.value - 1; const isVisibleInnerPage = (p) => (currentPage.value == 0 && p == 3) || ((currentPage.value == 0 || currentPage.value == 1) && p == 4) - || (p > currentPage.value-1 && p < currentPage.value+3) - || ((currentPage.value == pageCount.value-1 || currentPage.value == pageCount.value-2) && p == pageCount.value-3) - || (currentPage.value == pageCount.value-1 && p == pageCount.value-2) + || (p > currentPage.value - 1 && p < currentPage.value + 3) + || ((currentPage.value == pageCount.value - 1 || currentPage.value == pageCount.value - 2) && p == pageCount.value - 3) + || (currentPage.value == pageCount.value - 1 && p == pageCount.value - 2) || p == pageCount.value; diff --git a/frontend/src/elements/admin/AdminNav.vue b/frontend/src/elements/admin/AdminNav.vue new file mode 100644 index 000000000..c3eaca730 --- /dev/null +++ b/frontend/src/elements/admin/AdminNav.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 55b432ee1..cb376e0ff 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -68,6 +68,7 @@ "bookingSuccessfullyRequested": "Buchung erfolgreich angefragt!", "copiedToClipboard": "In Zwischenablage kopiert", "eventWasCreated": "Das Event wurde in deinem Kalender erstellt.", + "invitationGenerated": "Die Einladungscodes wurden erfolgreich generiert.", "invitationWasSent": "Eine Einladung wurde an deine E-Mail-Adresse gesendet.", "invitationWasSentContext": "Eine Einladung wurde an {email} gesendet.", "messageWasNotSent": "Die Nachricht konnte nicht gesendet werden. Bitte nochmal versuchen.", @@ -88,10 +89,13 @@ "addCalendar": "{provider} Kalender hinzufügen", "addDay": "Tag hinzufügen", "addTime": "Zeit hinzufügen", + "admin-invite-codes-panel": "Einladungen", + "admin-subscriber-panel": "Abonnenten", "all": "Alle", "allAppointments": "Alle Termine", "allDay": "Ganzer Tag", "allFutureAppointments": "Alle zukünftigen Termine", + "amountOfCodes": "Anzahl zu erzeugender Codes", "appearance": "Aussehen", "appointmentCreationError": "Fehler bei Terminerstellung", "appointmentDetails": "Termindetails", @@ -160,12 +164,14 @@ "filter": "Filter", "general": "Allgemein", "generalDetails": "Allgemeine Infos", + "generate": "Generieren", "generateZoomLink": "Zoom Meeting generieren", "goBack": "Zurück", "google": "Google", "guest": "{count} Gäste | {count} Gast | {count} Gäste", "immediately": "Sofort", "inPerson": "Vor Ort", + "inviteCode": "Du hast einen Einladungscode?", "language": "Sprache", "legal": "Impressum", "light": "Hell", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index e8c232b84..e9e3a1709 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -68,6 +68,7 @@ "bookingSuccessfullyRequested": "Booking request sent", "copiedToClipboard": "Copied to clipboard", "eventWasCreated": "Event created in your calendar.", + "invitationGenerated": "The invite codes were generated successfully.", "invitationWasSent": "An invitation was sent to your email.", "invitationWasSentContext": "An invitation was sent to {email}.", "messageWasNotSent": "Your message couldn't be sent. Please try again.", @@ -88,10 +89,13 @@ "addCalendar": "Add {provider} calendar", "addDay": "Add day", "addTime": "Add time", + "admin-invite-codes-panel": "Invites", + "admin-subscriber-panel": "Subscribers", "all": "All", "allAppointments": "All bookings", "allDay": "All day", "allFutureAppointments": "All future bookings", + "amountOfCodes": "Amount of codes to generate", "appearance": "Appearance", "appointmentCreationError": "Oh no! Something went wrong.", "appointmentDetails": "Details", @@ -160,12 +164,14 @@ "filter": "Filter", "general": "General", "generalDetails": "Details", + "generate": "Generate", "generateZoomLink": "Generate Zoom Meeting", "goBack": "Go Back", "google": "Google", "guest": "{count} guests | {count} guest | {count} guests", "immediately": "instant", "inPerson": "In person", + "inviteCode": "Have an invite code?", "language": "Choose language", "legal": "Legal", "light": "Light", diff --git a/frontend/src/router.js b/frontend/src/router.js index 713a367b4..7b7afd9fa 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -14,7 +14,8 @@ const AppointmentsView = defineAsyncComponent(() => import('@/views/Appointments const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView')); const ProfileView = defineAsyncComponent(() => import('@/views/ProfileView')); const LegalView = defineAsyncComponent(() => import('@/views/LegalView')); -const SubscriberPanelView = defineAsyncComponent(() => import('@/views/SubscriberPanelView')); +const SubscriberPanelView = defineAsyncComponent(() => import('@/views/admin/SubscriberPanelView')); +const InviteCodePanelView = defineAsyncComponent(() => import('@/views/admin/InviteCodePanelView.vue')); /** * Defined routes for Thunderbird Appointment @@ -91,11 +92,17 @@ const routes = [ name: 'terms', component: LegalView, }, + // Admin { path: '/admin/subscribers', name: 'admin-subscriber-panel', component: SubscriberPanelView, }, + { + path: '/admin/invites', + name: 'admin-invite-codes-panel', + component: InviteCodePanelView, + }, ]; // create router object to export diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 0ae888741..7dd54c909 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -8,11 +8,10 @@ {{ loginError }}
-
+