feat: Einladungslinks, mit denen sich Fahrer selbst ein Konto anlegen

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 22:09:14 +02:00
parent 83a1693ef1
commit 7ac5e9ab4f
8 changed files with 962 additions and 0 deletions

View file

@ -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":"<TEAM_ID>","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,

View file

@ -0,0 +1,145 @@
/// <reference path="../pb_data/types.d.ts" />
// 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())

View file

@ -0,0 +1,122 @@
/// <reference path="../pb_data/types.d.ts" />
// 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 }

View file

@ -0,0 +1,99 @@
/// <reference path="../pb_data/types.d.ts" />
// 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'))
})

View file

@ -0,0 +1,96 @@
/// <reference path="../pb_data/types.d.ts" />
// 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)
}
})

View file

@ -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<TeamInvitesResponse[]>([])
loading = $state(false)
error = $state<string | null>(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<string, unknown> = {
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<InviteTeam> {
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<TeamInviteStore>(KEY)
}

View file

@ -0,0 +1,228 @@
<script lang="ts">
/**
* Die Seite hinter einem Einladungslink — die einzige Seite der App, die
* ohne Login etwas tut.
*
* Sie spricht nicht mit den Collections, sondern mit den Routen aus
* backend/pb_hooks/team_invites.pb.js: Ein Nicht-Eingeloggter darf weder
* `team_invites` sehen noch ein Konto anlegen noch sich in ein Team
* eintragen. Der Hook darf all das — aber nur zusammen und nur gegen
* einen gültigen Token.
*
* Nach dem Anlegen meldet sich die Seite ganz normal mit E-Mail und
* Passwort an; es gibt keinen Sonderweg in die Anwendung.
*/
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { auth, getFileURL } from '$lib/stores/pocketbase.svelte'
import { publicInvite, type InviteTeam } from '$lib/stores/teamInvites.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Logo from '$lib/components/Logo.svelte'
import { UserPlus } from 'lucide-svelte'
import Spinner from '$lib/components/Spinner.svelte'
import { onMount } from 'svelte'
const token = $derived(page.params.token ?? '')
let loading = $state(true)
let team = $state<InviteTeam | null>(null)
let loadError = $state<string | null>(null)
let form = $state({ name: '', email: '', password: '', passwordConfirm: '' })
let busy = $state(false)
let error = $state<string | null>(null)
onMount(async () => {
try {
team = await publicInvite.load(token)
} catch (e: any) {
loadError = e.message ?? 'Diese Einladung gibt es nicht mehr.'
} finally {
loading = false
}
})
/** Das Logo des Teams — der Hook liefert den Dateinamen mit. */
const logoURL = $derived(team?.logo ? getFileURL(team, team.logo) : null)
async function submit(e: SubmitEvent) {
e.preventDefault()
if (form.password !== form.passwordConfirm) {
error = 'Die beiden Passwörter stimmen nicht überein.'
return
}
busy = true
error = null
try {
await publicInvite.accept(token, { ...form })
// Der Hook gibt bewusst kein Token zurück; die Anmeldung läuft
// durch dieselbe Tür wie jede andere.
auth.cookie = true
await auth.login(form.email, form.password)
if (auth.error) throw new Error(auth.error)
goto('/dashboard')
} catch (e: any) {
error = e.message ?? 'Das hat nicht geklappt.'
} finally {
busy = false
}
}
/** Schon angemeldet? Dann fehlt nur noch der Beitritt. */
async function join() {
busy = true
error = null
try {
await publicInvite.join(token)
goto('/dashboard')
} catch (e: any) {
error = e.message ?? 'Der Beitritt hat nicht geklappt.'
} finally {
busy = false
}
}
</script>
<svelte:head><title>Einladung</title></svelte:head>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<Card class="w-full max-w-md">
{#if loading}
<CardContent class="py-16 text-center text-muted-foreground">
<Spinner class="mx-auto mb-3 size-6" />
Einladung wird geprüft …
</CardContent>
{:else if loadError || !team}
<CardHeader class="justify-items-center gap-3 pt-2 text-center">
<Logo class="h-20 w-20" />
<CardTitle>Einladung ungültig</CardTitle>
<CardDescription>{loadError}</CardDescription>
</CardHeader>
<CardContent class="text-center">
<p class="mb-4 text-sm text-muted-foreground">
Frag im Team nach einem neuen Link — abgelaufene lassen sich nicht
wiederbeleben.
</p>
<Button variant="outline" href="/login">Zur Anmeldung</Button>
</CardContent>
{:else}
<CardHeader class="justify-items-center gap-3 pt-2 text-center">
{#if logoURL}
<img src={logoURL} alt="" class="h-20 w-20 object-contain" />
{:else}
<Logo class="h-20 w-20" />
{/if}
<CardTitle>Willkommen bei {team.name}</CardTitle>
<CardDescription>
{#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}
</CardDescription>
</CardHeader>
<CardContent>
{#if auth.user}
<!--
Wer schon ein Konto hat, soll sich kein zweites
anlegen. Für ihn bleibt genau ein Knopf.
-->
<div class="space-y-4">
{#if error}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<Button class="w-full" onclick={join} disabled={busy}>
{#if busy}
<Spinner class="mr-2 size-4" />
Tritt bei …
{:else}
<UserPlus class="mr-2 h-4 w-4" />
Team beitreten
{/if}
</Button>
<p class="text-center text-xs text-muted-foreground">
Nicht du? <a href="/login" class="underline">Mit anderem Konto anmelden</a>
</p>
</div>
{:else}
<form onsubmit={submit} class="space-y-4">
<div class="space-y-2">
<Label for="i-name">Name</Label>
<Input id="i-name" bind:value={form.name} disabled={busy} placeholder="Max Mustermann" required />
</div>
<div class="space-y-2">
<Label for="i-mail">E-Mail</Label>
<Input
id="i-mail"
type="email"
bind:value={form.email}
disabled={busy}
autocomplete="email"
placeholder="max@example.com"
required
/>
</div>
<div class="space-y-2">
<Label for="i-pw">Passwort</Label>
<Input
id="i-pw"
type="password"
bind:value={form.password}
disabled={busy}
autocomplete="new-password"
placeholder="mindestens 8 Zeichen"
minlength={8}
required
/>
</div>
<div class="space-y-2">
<Label for="i-pw2">Passwort wiederholen</Label>
<Input
id="i-pw2"
type="password"
bind:value={form.passwordConfirm}
disabled={busy}
autocomplete="new-password"
placeholder="••••••••"
required
/>
</div>
{#if error}
<div class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<Button type="submit" class="w-full" disabled={busy}>
{#if busy}
<Spinner class="mr-2 size-4" />
Konto wird angelegt …
{:else}
Konto anlegen und beitreten
{/if}
</Button>
<p class="text-center text-xs text-muted-foreground">
Schon ein Konto? <a href="/login" class="underline">Anmelden</a> und den
Link danach erneut öffnen.
</p>
</form>
{/if}
</CardContent>
{/if}
</Card>
</div>