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
193 lines
6.6 KiB
TypeScript
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)
|
|
}
|