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 }}
-
+