Kein fertiger Stand, sondern ein Zwischenstand: Hier wurde im September 2026 angefangen, die App auf „Kurbeler" umzubenennen — Wortmarke, Icons, brand.ts, Benachrichtigungen, das Benutzerverzeichnis. Weitergegangen ist es dann im eigenen Repo `~/Dokumente/Projekte/kurbeler`, das die vollständige Historie dieses Repos mitgenommen hat. Committet wird das hier nicht, weil es gebraucht würde, sondern damit es nicht als Haufen unversionierter Dateien im Arbeitsverzeichnis verrottet. Wer in zwei Jahren nachsieht, findet so eine Geschichte statt eines Rätsels — und das Stammtisch-Wappen, das nur hier je existiert hat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LTw6xVYgjMy9AQtfs1Gdyu
262 lines
11 KiB
Svelte
262 lines
11 KiB
Svelte
<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, type InviteRecipient } 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)
|
|
|
|
/**
|
|
* Für wen der Link gilt. Ein Einladungslink gilt jedem — dann steht hier
|
|
* `personal: false` und das Formular ist leer wie bisher. Eine
|
|
* persönliche Einladung ging an genau eine Adresse; die steht schon drin
|
|
* und lässt sich nicht ändern.
|
|
*/
|
|
let recipient = $state<InviteRecipient | null>(null)
|
|
|
|
let form = $state({ name: '', email: '', password: '', passwordConfirm: '' })
|
|
let busy = $state(false)
|
|
let error = $state<string | null>(null)
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const data = await publicInvite.load(token)
|
|
team = data.team
|
|
recipient = data.recipient
|
|
|
|
// Was der Server über den Eingeladenen weiß, muss er nicht noch
|
|
// einmal eintippen. Der Name steht im Kader, die Adresse hat die
|
|
// Mail erreicht.
|
|
if (data.recipient.personal) {
|
|
form.email = data.recipient.email
|
|
if (data.recipient.name) form.name = data.recipient.name
|
|
}
|
|
} 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 · Kurbeler</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 if recipient?.personal}
|
|
Such dir ein Passwort aus — mehr fehlt nicht. Als Fahrer stehst
|
|
du schon im Kader.
|
|
{: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>
|
|
<!--
|
|
Bei einer persönlichen Einladung steht die Adresse
|
|
fest: An sie ging die Mail, und nur für sie gilt der
|
|
Token. Das Feld ist deshalb gesperrt — dass ein
|
|
gesperrtes Feld nur eine Bitte ist, weiß der Hook und
|
|
prüft es beim Anlegen noch einmal.
|
|
-->
|
|
<Input
|
|
id="i-mail"
|
|
type="email"
|
|
bind:value={form.email}
|
|
disabled={busy || !!recipient?.personal}
|
|
readonly={!!recipient?.personal}
|
|
autocomplete="email"
|
|
placeholder="max@example.com"
|
|
required
|
|
/>
|
|
{#if recipient?.personal}
|
|
<p class="text-xs text-muted-foreground">
|
|
An diese Adresse ging die Einladung.
|
|
</p>
|
|
{/if}
|
|
</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>
|