stammtisch-hersbruck/backend/pb_hooks/team_invites.pb.js
Daniel Michelberger 7ac5e9ab4f 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
2026-09-07 22:09:14 +02:00

145 lines
5.2 KiB
JavaScript

/// <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())