From 7ac5e9ab4f2751113cc87ddecffc9c62c7009ac5 Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Mon, 7 Sep 2026 22:09:14 +0200 Subject: [PATCH] feat: Einladungslinks, mit denen sich Fahrer selbst ein Konto anlegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher legte die Teamleitung jedem Fahrer von Hand ein Konto an und gab das Passwort weiter. Bei zwanzig Leuten in einer WhatsApp-Gruppe ist ein Link die kuerzere Strecke. Die oeffentliche Seite der Einladung laeuft ueber eigene Routen in pb_hooks, nicht ueber die Collection: Ohne Login ist dort nichts sichtbar, auch nicht mit Token. Der Token kommt vom Server, ein mitgeschickter wird abgewiesen. Dazu der Login-Vorspann vor jeder Regel, die @request.auth.id erwaehnt. Das ist ein Loch und keine Kosmetik: Eine leere Relation ist in PocketBase gleich dem leeren @request.auth.id einer anonymen Anfrage — ein Team ohne Admins stuende sonst offen im Netz. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr --- backend/README.md | 79 ++++++ backend/pb_hooks/team_invites.pb.js | 145 +++++++++++ backend/pb_hooks/team_invites_lib.js | 122 ++++++++++ .../pb_migrations/1754501600_team_invites.js | 99 ++++++++ .../1754501700_rules_require_login.js | 96 ++++++++ .../{TeamSwitcher.svelte => TeamMenu.svelte} | 0 frontend/src/lib/stores/teamInvites.svelte.ts | 193 +++++++++++++++ .../src/routes/invite/[token]/+page.svelte | 228 ++++++++++++++++++ 8 files changed, 962 insertions(+) create mode 100644 backend/pb_hooks/team_invites.pb.js create mode 100644 backend/pb_hooks/team_invites_lib.js create mode 100644 backend/pb_migrations/1754501600_team_invites.js create mode 100644 backend/pb_migrations/1754501700_rules_require_login.js rename frontend/src/lib/components/{TeamSwitcher.svelte => TeamMenu.svelte} (100%) create mode 100644 frontend/src/lib/stores/teamInvites.svelte.ts create mode 100644 frontend/src/routes/invite/[token]/+page.svelte diff --git a/backend/README.md b/backend/README.md index 9c21aa9..d60c210 100644 --- a/backend/README.md +++ b/backend/README.md @@ -50,6 +50,85 @@ Ohne diesen Schritt ist die Änderung beim nächsten `--build` verloren. Ein frisch gestarteter Container hat damit dasselbe Schema wie die produktive Instanz — aber **keine** Daten. +### Regeln: der Login-Vorspann ist Pflicht + +Jede Regel, die `@request.auth.id` erwähnt, beginnt mit +`@request.auth.id != "" && (…)`. Das ist kein Stilmittel, sondern schließt ein +Loch: + +**Eine leere Relation ist in PocketBase gleich dem leeren `@request.auth.id` +einer anonymen Anfrage.** `team.admins.id ?= @request.auth.id` ist damit für +jedes Team **ohne Admins** wahr — und ohne Admins ist jedes frisch angelegte +Team. Dasselbe gilt für jede optionale Relation: `created_by`, `stewards`, +`rider.user`. + +Nachgewiesen an einer lokalen Instanz mit dem Schema aus diesem Repo: Ein +`POST` auf `/api/collections/riders/records` **ohne jeden Token** legte den +Fahrer an. Betroffen waren nach demselben Muster `teams` (update), `riders`, +`trails`, `trail_versions`, `trail_flags`, `trail_markers`, `trail_comments`, +`event_series` und `event_participants`. + +`1754501700_rules_require_login.js` setzt den Vorspann vor jede betroffene +Regel. Er kann nichts erlauben, was vorher verboten war — er nimmt nur die +anonyme Anfrage heraus, die keine dieser Regeln je erfüllen sollte. Gelesen +werden die Regeln aus der Datenbank, eine Handänderung aus dem Admin-UI wird +also abgesichert und nicht überschrieben. + +Nach dem Deploy einmal gegenprüfen — das muss scheitern: + + curl -X POST https://api.stammtisch-hersbruck.de/api/collections/riders/records \ + -H 'Content-Type: application/json' \ + -d '{"team":"","name":"Test"}' + +Wer eine neue Regel schreibt, setzt den Vorspann mit. Sonst reißt dasselbe +Loch an der nächsten Stelle wieder auf. + +### `1754501300` scheitert auf einer frischen Datenbank + +Bekannt und **nicht behoben**: Bei `docker compose up --build` mit leerem +`pb_data` legt der Snapshot `1754400000` die Collection `riders` bereits ohne +das Feld `event` an. `1754501300_event_participants.js` sucht danach mit +`findRecordsByFilter('riders', 'event != "")` und bricht mit +`unknown field "event"` ab — PocketBase startet dann gar nicht. + +Auf der produktiven Instanz tritt das nicht auf, dort gibt es das Feld noch. +Angefasst wurde die Migration deshalb nicht; wer lokal von Null startet, +braucht diese eine Zeile: + + const existing = riders.fields.getByName('event') + ? app.findRecordsByFilter('riders', 'event != ""', '', 0, 0) + : [] + +## Hooks (`pb_hooks/`) + +Serverseitiges JavaScript, das PocketBase beim Start lädt (`--hooksDir`). Wie +`pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung +wirkt also erst nach `docker compose up -d --build`. + +Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks. + +| Datei | Zweck | +|---|---| +| `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` | +| `team_invites_lib.js` | Gemeinsame Helfer der drei Routen | + +Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht +angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel +einzeln erlauben darf: ein Konto anlegen, sich in `teams.users` eintragen und +einen Fahrer anlegen. Ein Hook arbeitet an den Regeln vorbei und kann genau +diese Kombination erlauben — gegen einen gültigen Token und sonst nicht. + +**Zwei Fallstricke, an denen die erste Fassung scheiterte:** + +1. Jeder Route-Handler läuft in einer **eigenen** JS-Laufzeit. Funktionen, die + in derselben Datei neben `routerAdd` stehen, sind im Handler *nicht* + sichtbar (`ReferenceError`). Gemeinsamer Code gehört deshalb in ein Modul, + das der Handler sich per ``require(`${__hooks}/…`)`` selbst holt. +2. Was `record.get()` bei einer Mehrfach-Relation liefert, ist ein Go-Slice + und kein JS-Array — `indexOf` und Konsorten sind darauf nicht verlässlich. + Anhängen geht über den Modifier `record.set('users+', id)`, Prüfen über + einen Filter. + ## Datenpersistenz Sämtliche Laufzeitdaten liegen in der SQLite-DB unter `/pb/pb_data`: Datensätze, diff --git a/backend/pb_hooks/team_invites.pb.js b/backend/pb_hooks/team_invites.pb.js new file mode 100644 index 0000000..6d30f32 --- /dev/null +++ b/backend/pb_hooks/team_invites.pb.js @@ -0,0 +1,145 @@ +/// + +// Die öffentliche Seite der Einladungslinks. +// +// Warum überhaupt eigene Routen? Wer einem Team über einen Link beitritt, ist +// noch nicht eingeloggt und muss dabei drei Dinge tun, die keine +// Collection-Regel erlauben darf: +// +// 1. ein Konto anlegen — `users.createRule` verlangt einen Login, sonst +// wäre es eine offene Registrierung für jedermann; +// 2. sich in `teams.users` eintragen — das darf sonst nur die Teamleitung; +// 3. einen Fahrer im Kader anlegen — dito. +// +// Ein Hook läuft an den Regeln vorbei und kann deshalb genau diese drei +// Schritte erlauben, aber nur zusammen und nur gegen einen gültigen Token. +// Die Collection `team_invites` selbst bleibt für Nicht-Mitglieder unsichtbar. +// +// Routen: +// GET /api/team-invites/{token} — öffentlich, zeigt das Team +// POST /api/team-invites/{token}/accept — öffentlich, Konto + Beitritt +// POST /api/team-invites/{token}/join — eingeloggt, nur Beitritt +// +// Jeder Handler holt sich seine Helfer selbst per require(): PocketBase führt +// Route-Handler in einer eigenen JS-Laufzeit aus, der Dateikopf hier ist dort +// nicht sichtbar. + +// --- Wofür gilt der Link? --------------------------------------------------- +// +// Öffentlich, weil die Seite den Teamnamen zeigen soll, bevor jemand seine +// Daten einträgt. Mehr als Name und Logo gibt sie nicht preis; die Einladung +// selbst — Zähler, Ablauf, wer sie erzeugt hat — bleibt drin. + +routerAdd('GET', '/api/team-invites/{token}', (e) => { + const lib = require(`${__hooks}/team_invites_lib.js`) + + const invite = lib.findValidInvite(e.app, e.request.pathValue('token')) + if (!invite) { + throw new NotFoundError('Diese Einladung gibt es nicht mehr.') + } + + return e.json(200, { team: lib.teamPayload(e.app, invite) }) +}) + +// --- Konto anlegen und beitreten ------------------------------------------- + +routerAdd('POST', '/api/team-invites/{token}/accept', (e) => { + const lib = require(`${__hooks}/team_invites_lib.js`) + + const data = new DynamicModel({ + email: '', + password: '', + passwordConfirm: '', + name: '', + }) + e.bindBody(data) + + const email = (data.email || '').trim() + const name = (data.name || '').trim() + + if (!name) { + throw new BadRequestError('Bitte einen Namen angeben.') + } + if (email.indexOf('@') < 1) { + throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.') + } + if ((data.password || '').length < 8) { + throw new BadRequestError('Das Passwort braucht mindestens 8 Zeichen.') + } + if (data.password !== data.passwordConfirm) { + throw new BadRequestError('Die beiden Passwörter stimmen nicht überein.') + } + + let result + e.app.runInTransaction((txApp) => { + // Innerhalb der Transaktion noch einmal nachsehen: Zwischen Anzeige + // der Seite und dem Absenden kann die Einladung abgelaufen, + // aufgebraucht oder gelöscht worden sein. + const invite = lib.findValidInvite(txApp, e.request.pathValue('token')) + if (!invite) { + throw new BadRequestError('Diese Einladung gilt nicht mehr.') + } + + // Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique". + // Der Hinweis auf den zweiten Weg ist hier mehr wert als die + // Fehlermeldung. + let existing = null + try { + existing = txApp.findAuthRecordByEmail('users', email) + } catch (err) { + existing = null + } + + if (existing) { + throw new BadRequestError( + 'Zu dieser E-Mail-Adresse gibt es schon ein Konto. ' + + 'Melde dich damit an und öffne den Link danach erneut.', + ) + } + + const user = new Record(txApp.findCollectionByNameOrId('users')) + user.setEmail(email) + user.setPassword(data.password) + user.setVerified(false) + user.set('emailVisibility', false) + user.set('name', name) + txApp.save(user) + + result = lib.joinTeam(txApp, invite, user, name) + }) + + // Kein Auth-Token in der Antwort: Die App meldet sich anschließend ganz + // normal mit E-Mail und Passwort an und läuft damit durch dieselbe + // Anmeldung wie jeder andere auch. + return e.json(200, { ok: true, team: result.teamId }) +}) + +// --- Als bereits angemeldeter Nutzer beitreten ------------------------------ + +routerAdd('POST', '/api/team-invites/{token}/join', (e) => { + const lib = require(`${__hooks}/team_invites_lib.js`) + + const data = new DynamicModel({ name: '' }) + e.bindBody(data) + + const user = e.auth + if (!user) { + throw new UnauthorizedError() + } + + let result + e.app.runInTransaction((txApp) => { + const invite = lib.findValidInvite(txApp, e.request.pathValue('token')) + if (!invite) { + throw new BadRequestError('Diese Einladung gilt nicht mehr.') + } + + // Fällt der Name weg, stünde der Beitretende ohne Namen im Kader. Der + // Kontoname ist die bessere Näherung als gar nichts. + const name = (data.name || '').trim() || user.getString('name') + + result = lib.joinTeam(txApp, invite, user, name) + }) + + return e.json(200, { ok: true, team: result.teamId }) +}, $apis.requireAuth()) diff --git a/backend/pb_hooks/team_invites_lib.js b/backend/pb_hooks/team_invites_lib.js new file mode 100644 index 0000000..ee9e104 --- /dev/null +++ b/backend/pb_hooks/team_invites_lib.js @@ -0,0 +1,122 @@ +/// + +// Gemeinsamer Code der Einladungs-Routen. +// +// Warum eine eigene Datei? PocketBase führt jeden Route-Handler in einer +// eigenen JS-Laufzeit aus. Was in team_invites.pb.js neben routerAdd steht, +// ist im Handler nicht sichtbar — der Handler kennt nur seine eigenen +// Variablen, die Globals und was er sich per require() holt. + +const INVITES = 'team_invites' + +/** + * Die Einladung zum Token, sofern sie gilt — sonst null. + * + * `expires` kommt als "2026-09-30 10:00:00.000Z" zurück, leer heißt: läuft + * nicht ab. `max_uses` 0 heißt: beliebig oft. + */ +function findValidInvite(app, token) { + if (!token) { + return null + } + + let invite + try { + invite = app.findFirstRecordByFilter(INVITES, 'token = {:token}', { token: token }) + } catch (err) { + return null + } + + const expires = invite.getString('expires') + if (expires) { + const ts = Date.parse(expires.replace(' ', 'T')) + if (!isNaN(ts) && ts < Date.now()) { + return null + } + } + + const maxUses = invite.getInt('max_uses') + if (maxUses > 0 && invite.getInt('uses') >= maxUses) { + return null + } + + return invite +} + +/** Team der Einladung samt Logo — genug, damit die Seite es benennen kann. */ +function teamPayload(app, invite) { + const team = app.findRecordById('teams', invite.getString('team')) + + return { + id: team.id, + collectionId: team.collection().id, + collectionName: 'teams', + name: team.getString('name'), + logo: team.getString('logo'), + } +} + +/** + * Trägt einen bestehenden Nutzer ins Team ein und legt ihm einen Fahrer an, + * falls er dort noch keinen hat. Zählt die Einladung hoch. + * + * Erwartet einen txApp — beide Aufrufer stecken in einer Transaktion. + */ +function joinTeam(txApp, invite, user, name) { + const team = txApp.findRecordById('teams', invite.getString('team')) + + // Mitgliedschaft über einen Filter prüfen und nicht über das Feld: Was + // `get()` bei einer Mehrfach-Relation zurückgibt, ist ein Go-Slice und + // kein JS-Array — `indexOf` darauf ist nicht verlässlich. + let isMember = false + try { + txApp.findFirstRecordByFilter( + 'teams', + 'id = {:team} && users.id ?= {:user}', + { team: team.id, user: user.id }, + ) + isMember = true + } catch (err) { + isMember = false + } + + if (!isMember) { + // "+" hängt an, statt die Liste zu ersetzen — sonst flöge bei zwei + // gleichzeitigen Beitritten einer der beiden wieder raus. + team.set('users+', user.id) + txApp.save(team) + } + + // Jedes Teammitglied ist ein Fahrer. Hat der Nutzer im Team schon einen, + // bleibt es bei dem — ein zweiter wäre dieselbe Person doppelt im Kader. + let rider = null + try { + rider = txApp.findFirstRecordByFilter( + 'riders', + 'team = {:team} && user = {:user}', + { team: team.id, user: user.id }, + ) + } catch (err) { + rider = null + } + + if (!rider) { + rider = new Record(txApp.findCollectionByNameOrId('riders')) + rider.set('team', team.id) + rider.set('name', name) + rider.set('user', user.id) + txApp.save(rider) + } + + // Gezählt wird, wer über den Link ins Team gekommen ist — nicht, wie oft + // jemand darauf geklickt hat. Sonst wäre ein zweiter Klick eines schon + // aufgenommenen Mitglieds bei `max_uses = 1` der letzte für alle anderen. + if (!isMember) { + invite.set('uses', invite.getInt('uses') + 1) + txApp.save(invite) + } + + return { teamId: team.id, riderId: rider.id, joined: !isMember } +} + +module.exports = { findValidInvite, teamPayload, joinTeam } diff --git a/backend/pb_migrations/1754501600_team_invites.js b/backend/pb_migrations/1754501600_team_invites.js new file mode 100644 index 0000000..92e269e --- /dev/null +++ b/backend/pb_migrations/1754501600_team_invites.js @@ -0,0 +1,99 @@ +/// + +// Einladungslinks: Ein Team gibt einen Link heraus, über den sich Fahrer +// selbst ein Konto anlegen und dem Team beitreten. +// +// Bisher ging das nur andersherum — die Teamleitung legte jedem Fahrer von +// Hand ein Konto an und gab das Passwort weiter. Bei zwanzig Leuten in einer +// WhatsApp-Gruppe ist ein Link die kürzere Strecke. +// +// Der Link ist das Geheimnis. Deshalb: +// +// 1) `token` wird von PocketBase erzeugt (autogeneratePattern) und ist +// eindeutig. Ein mitgeschickter Token wird über die createRule abgelehnt, +// sonst könnte sich jemand einen leicht zu ratenden aussuchen. +// +// 2) Lesen, anlegen und löschen darf nur, wer das Team führt. Es gibt +// bewusst keine updateRule: Ein Link ändert sich nicht, er wird gelöscht +// und neu erzeugt. Den Zähler `uses` schreibt der Hook, der an den Regeln +// vorbei arbeitet. +// +// 3) Die öffentliche Seite der Einladung läuft nicht über diese Collection, +// sondern über die Routen in pb_hooks/team_invites.pb.js. Ohne Login ist +// hier nichts sichtbar — auch nicht mit Token. +// +// `expires` leer heißt: läuft nicht ab. `max_uses` 0 heißt: beliebig oft. + +// Der Login-Vorspann ist nicht bloß Gürtel und Hosenträger: Ist `admins` +// leer, vergleicht PocketBase eine leere Relation mit dem leeren +// `@request.auth.id` einer anonymen Anfrage — und das ist wahr. Ohne den +// Vorspann stünde jede Einladung eines Teams ohne Admins offen im Netz. +// Siehe 1754501700_rules_require_login.js. +const ADMIN_RULE = + '@request.auth.id != ""' + + ' && (team.owner.id ?= @request.auth.id || team.admins.id ?= @request.auth.id)' + +migrate((app) => { + const teams = app.findCollectionByNameOrId('teams') + const users = app.findCollectionByNameOrId('users') + + try { + app.findCollectionByNameOrId('team_invites') + return + } catch { + // Noch nicht da — wird gleich angelegt. + } + + const invites = new Collection({ + id: 'pbc_team_invites', + type: 'base', + name: 'team_invites', + listRule: ADMIN_RULE, + viewRule: ADMIN_RULE, + // Der Token kommt vom Server. `:isset = false` weist einen + // mitgeschickten ab, statt ihn stillschweigend zu übernehmen. + createRule: ADMIN_RULE + ' && @request.body.token:isset = false', + updateRule: null, + deleteRule: ADMIN_RULE, + fields: [ + { + id: 'relation_invite_team', + name: 'team', + type: 'relation', + required: true, + maxSelect: 1, + collectionId: teams.id, + cascadeDelete: true, + }, + { + id: 'text_invite_token', + name: 'token', + type: 'text', + required: true, + min: 32, + max: 32, + autogeneratePattern: '[a-zA-Z0-9]{32}', + }, + { + id: 'relation_invite_created_by', + name: 'created_by', + type: 'relation', + maxSelect: 1, + collectionId: users.id, + cascadeDelete: false, + }, + { id: 'date_invite_expires', name: 'expires', type: 'date' }, + { id: 'number_invite_max_uses', name: 'max_uses', type: 'number', min: 0, onlyInt: true }, + { id: 'number_invite_uses', name: 'uses', type: 'number', min: 0, onlyInt: true }, + { id: 'autodate_created', name: 'created', type: 'autodate', onCreate: true, onUpdate: false }, + { id: 'autodate_updated', name: 'updated', type: 'autodate', onCreate: true, onUpdate: true }, + ], + indexes: [ + 'CREATE UNIQUE INDEX `idx_team_invites_token` ON `team_invites` (`token`)', + ], + }) + + app.save(invites) +}, (app) => { + app.delete(app.findCollectionByNameOrId('team_invites')) +}) diff --git a/backend/pb_migrations/1754501700_rules_require_login.js b/backend/pb_migrations/1754501700_rules_require_login.js new file mode 100644 index 0000000..1397099 --- /dev/null +++ b/backend/pb_migrations/1754501700_rules_require_login.js @@ -0,0 +1,96 @@ +/// + +// Jede Regel, die auf `@request.auth.id` zeigt, bekommt einen Login-Vorspann. +// +// ACHTUNG, das ist ein Loch und keine Kosmetik: Eine *leere* Relation ist in +// PocketBase gleich dem *leeren* `@request.auth.id` einer anonymen Anfrage. +// `team.admins.id ?= @request.auth.id` ist damit für jedes Team ohne Admins +// wahr — auch ohne jeden Login. Dasselbe gilt für jede optionale Relation, +// die leer sein kann: `created_by`, `stewards`, `rider.user`. +// +// Nachgewiesen an einer frischen Instanz mit einem Team ohne Admins: +// Ein POST auf /api/collections/riders/records **ganz ohne Token** legte den +// Fahrer an. Betroffen sind nach demselben Muster +// +// teams update (admins leer) +// riders create/update/delete +// trails update/delete, trail_versions create/delete +// trail_flags create/update/delete +// trail_markers update/delete (created_by, stewards) +// trail_comments update/delete (created_by, stewards) +// event_series update/delete (created_by) +// event_participants create/update/delete (event.created_by) +// +// Der Vorspann `@request.auth.id != "" && (…)` kann nichts erlauben, was +// vorher verboten war — er nimmt nur die anonyme Anfrage heraus, die keine +// dieser Regeln je erfüllen sollte. Deshalb wird er stumpf vor jede Regel +// gesetzt, die `@request.auth.id` erwähnt, statt die betroffenen Stellen +// einzeln zu suchen: Was hier nicht auffällt, fällt sonst gar nicht auf. +// +// Gelesen werden die Regeln aus der Datenbank, nicht aus dieser Datei. Eine +// im Admin-UI von Hand geänderte Regel wird also mit ihrem eigenen Wortlaut +// abgesichert und nicht überschrieben. + +const GUARD = '@request.auth.id != ""' + +const COLLECTIONS = [ + 'users', 'teams', 'events', 'runs', 'riders', 'times', + 'trails', 'trail_versions', 'trail_flags', 'trail_markers', 'trail_comments', + 'event_participants', 'event_series', 'team_invites', +] + +const RULES = ['listRule', 'viewRule', 'createRule', 'updateRule', 'deleteRule'] + +migrate((app) => { + for (const name of COLLECTIONS) { + let collection + try { + collection = app.findCollectionByNameOrId(name) + } catch (err) { + continue // Collection gibt es auf dieser Instanz (noch) nicht. + } + + let changed = false + + for (const key of RULES) { + const rule = collection[key] + + // Leere Regel heißt „nur Superuser" und braucht keinen Schutz; + // eine Regel ohne @request.auth.id ist nicht gemeint. + if (!rule || rule.indexOf('@request.auth.id') === -1) continue + if (rule.indexOf(GUARD) === 0) continue + + collection[key] = GUARD + ' && (' + rule + ')' + changed = true + } + + if (changed) app.save(collection) + } +}, (app) => { + const prefix = GUARD + ' && (' + + for (const name of COLLECTIONS) { + // team_invites bringt den Vorspann aus 1754501600 mit; ihn hier + // abzuräumen hieße, ein Loch zu graben, das es vorher nicht gab. + if (name === 'team_invites') continue + + let collection + try { + collection = app.findCollectionByNameOrId(name) + } catch (err) { + continue + } + + let changed = false + + for (const key of RULES) { + const rule = collection[key] + if (!rule || rule.indexOf(prefix) !== 0 || rule.charAt(rule.length - 1) !== ')') continue + + collection[key] = rule.slice(prefix.length, -1) + changed = true + } + + if (changed) app.save(collection) + } +}) diff --git a/frontend/src/lib/components/TeamSwitcher.svelte b/frontend/src/lib/components/TeamMenu.svelte similarity index 100% rename from frontend/src/lib/components/TeamSwitcher.svelte rename to frontend/src/lib/components/TeamMenu.svelte diff --git a/frontend/src/lib/stores/teamInvites.svelte.ts b/frontend/src/lib/stores/teamInvites.svelte.ts new file mode 100644 index 0000000..7009060 --- /dev/null +++ b/frontend/src/lib/stores/teamInvites.svelte.ts @@ -0,0 +1,193 @@ +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import type { TeamInvitesResponse } from '$lib/types' + +const KEY = Symbol('teamInvite') + +/** + * Einladungslinks eines Teams. + * + * Zwei Seiten, zwei Wege: Die Teamleitung legt Links über die Collection an + * und sieht sie dort — das regeln die Regeln in + * 1754501600_team_invites.js. Wer einen Link einlöst, ist noch nicht + * eingeloggt und läuft über die öffentlichen Routen aus + * backend/pb_hooks/team_invites.pb.js; die stehen unten als `invites.public`. + */ +export class TeamInviteStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private teams = getTeamContext() + + get scoped(): TeamInvitesResponse[] { + const teamId = this.teams.activeId + if (!teamId) return [] + return this.records.filter((r) => r.team === teamId) + } + + /** + * Einladungen verwaltet nur die Teamleitung — dieselbe Regel wie in + * list/create/deleteRule der Collection. Die Prüfung hier blendet nur UI + * aus; durchgesetzt wird sie serverseitig. + */ + get canManage(): boolean { + return this.teams.isAdmin() + } + + /** Gilt der Link noch? Abgelaufen und aufgebraucht sehen gleich aus. */ + isValid(invite: TeamInvitesResponse): boolean { + if (invite.expires && new Date(invite.expires).getTime() < Date.now()) return false + const max = invite.max_uses ?? 0 + return !(max > 0 && (invite.uses ?? 0) >= max) + } + + /** Der Link zum Weitergeben. Ohne Fenster (SSR) gibt es keinen Host. */ + url(invite: TeamInvitesResponse): string { + const origin = typeof location !== 'undefined' ? location.origin : '' + return `${origin}/invite/${invite.token}` + } + + async load() { + // Ein einfaches Mitglied bekäme hier ohnehin eine leere Liste; der + // Aufruf entfällt trotzdem, damit das Dashboard beim Start eine + // Anfrage weniger stellt. + if (!auth.user) return + + this.loading = true + this.error = null + try { + this.records = await api.collection('team_invites').getFullList({ + sort: '-created', + requestKey: null, + }) + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Einladungen' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('team_invites').subscribe('*', (e) => { + const idx = this.records.findIndex((r) => r.id === e.record.id) + if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] + else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record + else if (e.action === 'delete' && idx !== -1) { + this.records = this.records.filter((r) => r.id !== e.record.id) + } + }).then((unsub) => { + this.unsubscribe = unsub + }).catch((e) => console.warn('team_invites subscribe failed:', e)) + } + + /** + * Neuen Link erzeugen. Den Token setzt PocketBase selbst — ihn + * mitzuschicken lehnt die createRule ab, damit sich niemand einen leicht + * zu ratenden aussuchen kann. + * + * `days` 0 heißt: läuft nicht ab, `maxUses` 0: beliebig oft einlösbar. + */ + async create({ days = 0, maxUses = 0 } = {}, teamId = this.teams.activeId) { + if (!teamId) throw new Error('Kein aktives Team') + + const data: Record = { + team: teamId, + max_uses: maxUses, + uses: 0, + created_by: auth.user?.id ?? '', + } + + if (days > 0) { + const until = new Date() + until.setDate(until.getDate() + days) + data.expires = until.toISOString() + } + + const invite = await api.collection('team_invites').create(data) + this.records = [invite, ...this.records] + return invite as TeamInvitesResponse + } + + /** + * Link zurückziehen. Ohne Rückfrage: Ein Einladungslink ist nichts, was + * verloren gehen kann — wer einen braucht, erzeugt den nächsten. + */ + async remove(id: string) { + await api.collection('team_invites').delete(id) + this.records = this.records.filter((r) => r.id !== id) + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +/** + * Die öffentliche Seite einer Einladung — ohne Login, ohne Store, ohne Team. + * Sie läuft gegen die Routen aus backend/pb_hooks/team_invites.pb.js, nicht + * gegen eine Collection: Ein Nicht-Eingeloggter darf `team_invites` nicht + * einmal sehen. + */ +export type InviteTeam = { + id: string + collectionId: string + collectionName: string + name: string + logo: string +} + +async function send(path: string, body?: unknown) { + const res = await fetch(`${api.baseURL.replace(/\/$/, '')}${path}`, { + method: body === undefined ? 'GET' : 'POST', + headers: { + 'Content-Type': 'application/json', + // Für /join muss der Server wissen, wer beitritt. + ...(api.authStore.token ? { Authorization: api.authStore.token } : {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + + const data = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(data?.message || 'Die Einladung konnte nicht geöffnet werden.') + + return data +} + +export const publicInvite = { + /** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */ + async load(token: string): Promise { + const data = await send(`/api/team-invites/${encodeURIComponent(token)}`) + return data.team as InviteTeam + }, + + /** Konto anlegen und beitreten. */ + async accept( + token: string, + data: { email: string; password: string; passwordConfirm: string; name: string }, + ) { + return await send(`/api/team-invites/${encodeURIComponent(token)}/accept`, data) + }, + + /** Als bereits angemeldeter Nutzer beitreten. */ + async join(token: string, data: { name?: string } = {}) { + return await send(`/api/team-invites/${encodeURIComponent(token)}/join`, data) + }, +} + +export function setTeamInviteContext() { + const store = new TeamInviteStore() + setContext(KEY, store) + return store +} + +export function getTeamInviteContext(): TeamInviteStore { + return getContext(KEY) +} diff --git a/frontend/src/routes/invite/[token]/+page.svelte b/frontend/src/routes/invite/[token]/+page.svelte new file mode 100644 index 0000000..fb24233 --- /dev/null +++ b/frontend/src/routes/invite/[token]/+page.svelte @@ -0,0 +1,228 @@ + + +Einladung + +
+ + {#if loading} + + + Einladung wird geprüft … + + {:else if loadError || !team} + + + Einladung ungültig + {loadError} + + +

+ Frag im Team nach einem neuen Link — abgelaufene lassen sich nicht + wiederbeleben. +

+ +
+ {:else} + + {#if logoURL} + + {:else} + + {/if} + Willkommen bei {team.name} + + {#if auth.user} + Du bist als {auth.user.name || auth.user.email} angemeldet. + {:else} + Leg dir ein Konto an — danach stehst du als Fahrer im Kader. + {/if} + + + + + {#if auth.user} + +
+ {#if error} +
+ {error} +
+ {/if} + +

+ Nicht du? Mit anderem Konto anmelden +

+
+ {:else} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if error} +
+ {error} +
+ {/if} + + + +

+ Schon ein Konto? Anmelden und den + Link danach erneut öffnen. +

+
+ {/if} +
+ {/if} +
+