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:
parent
83a1693ef1
commit
7ac5e9ab4f
8 changed files with 962 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
145
backend/pb_hooks/team_invites.pb.js
Normal file
145
backend/pb_hooks/team_invites.pb.js
Normal 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())
|
||||
122
backend/pb_hooks/team_invites_lib.js
Normal file
122
backend/pb_hooks/team_invites_lib.js
Normal 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 }
|
||||
99
backend/pb_migrations/1754501600_team_invites.js
Normal file
99
backend/pb_migrations/1754501600_team_invites.js
Normal 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'))
|
||||
})
|
||||
96
backend/pb_migrations/1754501700_rules_require_login.js
Normal file
96
backend/pb_migrations/1754501700_rules_require_login.js
Normal 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)
|
||||
}
|
||||
})
|
||||
193
frontend/src/lib/stores/teamInvites.svelte.ts
Normal file
193
frontend/src/lib/stores/teamInvites.svelte.ts
Normal 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)
|
||||
}
|
||||
228
frontend/src/routes/invite/[token]/+page.svelte
Normal file
228
frontend/src/routes/invite/[token]/+page.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue