stammtisch-hersbruck/frontend/src/lib/stores/teamInvites.svelte.ts
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

193 lines
6.6 KiB
TypeScript

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