stammtisch-hersbruck/backend/pb_migrations/1754501600_team_invites.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

99 lines
3.9 KiB
JavaScript

/// <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'))
})