feat: Profilbild als Knopf, und das eigene Konto laesst sich loeschen

Das Bild steht links ueber Name und Adresse und ist zugleich der Knopf:
Das Dateifeld liegt unsichtbar darunter, angeklickt wird das Bild. Ein
Label "Bild" ueber einem Dateifeld sagte nichts, was das Bild nicht
schon zeigt. Dazu eine Vorschau der Wahl — ohne sie bliebe sie
unsichtbar, weil es keinen Dateinamen mehr zu lesen gibt.

Loeschen des eigenen Kontos erlaubt users.deleteRule laengst
(id = @request.auth.id); es fehlte nur der Weg dorthin. Die Fahrer
bleiben stehen: riders.user haengt ohne Kaskade daran, also behaelt das
Team Namen, Zeiten und Teilnahmen und die Person verliert nur ihr Login.

Wer ein Team besitzt, kommt nicht durch. teams.owner ist ein Pflichtfeld
ohne Kaskade; PocketBase muesste es beim Loeschen leeren und braeche
dabei den Datensatz. Der Versuch endete in einem Fehler, den niemand
deuten kann — deshalb steht die Bedingung vorne und im Klartext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
This commit is contained in:
Daniel Michelberger 2026-09-07 22:11:44 +02:00
parent b3d77dddbe
commit 041bed923b

View file

@ -5,16 +5,40 @@
* also niemand für jemand anderen ändern. * also niemand für jemand anderen ändern.
*/ */
import { api, auth } from '$lib/stores/pocketbase.svelte' import { api, auth } from '$lib/stores/pocketbase.svelte'
import { app } from '$lib/stores/app.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { goto } from '$app/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import Avatar from '$lib/components/Avatar.svelte' import Avatar from '$lib/components/Avatar.svelte'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { KeyRound, User } from 'lucide-svelte' import { Camera, KeyRound, Trash2, User } from 'lucide-svelte'
let name = $state(auth.user?.name ?? '') let name = $state(auth.user?.name ?? '')
let avatarFile = $state<File | null>(null) let avatarFile = $state<File | null>(null)
/**
* Vorschau des gewaehlten Bildes. Ohne sie bliebe die Wahl unsichtbar —
* das Dateifeld ist versteckt, es gibt also keinen Dateinamen zu lesen.
* Die Object-URL wird beim naechsten Wechsel wieder freigegeben, sonst
* haelt der Browser jedes einmal angesehene Bild bis zum Seitenwechsel
* fest.
*/
let avatarPreview = $state<string | null>(null)
$effect(() => {
const file = avatarFile
if (!file) {
avatarPreview = null
return
}
const url = URL.createObjectURL(file)
avatarPreview = url
return () => URL.revokeObjectURL(url)
})
let savingProfile = $state(false) let savingProfile = $state(false)
let profileMessage = $state<string | null>(null) let profileMessage = $state<string | null>(null)
let profileError = $state<string | null>(null) let profileError = $state<string | null>(null)
@ -50,6 +74,56 @@
} }
} }
// --- Eigenes Konto löschen -------------------------------------------
//
// `users.deleteRule = id = @request.auth.id` erlaubt genau das und sonst
// nichts: Niemand löscht ein fremdes Konto, auch die Teamleitung nicht.
//
// Was dabei bleibt: die Fahrer, die an diesem Konto hingen. Deren
// Verknüpfung ist ohne Kaskade angelegt (riders.user, cascadeDelete
// false) — der Fahrer bleibt also mit Namen, Zeiten und Teilnahmen im
// Kader stehen und hat nur kein Login mehr. Das ist die Absicht:
// Ergebnisse gehören dem Team, nicht dem Konto.
//
// Wer ein Team besitzt, kommt hier nicht durch. `teams.owner` ist ein
// Pflichtfeld ohne Kaskade; PocketBase müsste es beim Löschen leeren und
// bräche dabei den Datensatz. Der Versuch endet in einem Fehler, den
// niemand deuten kann — deshalb steht die Bedingung hier vorne und im
// Klartext.
const teams = getTeamContext()
const ownedTeams = $derived(teams.records.filter((t) => t.owner === auth.user?.id))
let deletingAccount = $state(false)
let deleteError = $state<string | null>(null)
function deleteAccount() {
if (!auth.user || ownedTeams.length > 0) return
app.confirm.request({
title: 'Konto löschen?',
text:
'Dein Konto wird endgültig gelöscht und du wirst abgemeldet. ' +
'Dein Fahrer bleibt mit allen Zeiten und Teilnahmen im Kader stehen — ' +
'nur anmelden kannst du dich nicht mehr. Über einen neuen Einladungslink ' +
'kommst du zurück.',
yes: async () => {
deletingAccount = true
deleteError = null
try {
await api.collection('users').delete(auth.user!.id)
app.confirm.close()
await auth.logout()
goto('/login', { replaceState: true })
} catch (e: any) {
app.confirm.close()
deleteError = e.message ?? 'Das Konto konnte nicht gelöscht werden.'
} finally {
deletingAccount = false
}
},
no: () => app.confirm.close(),
})
}
let oldPassword = $state('') let oldPassword = $state('')
let newPassword = $state('') let newPassword = $state('')
let confirmPassword = $state('') let confirmPassword = $state('')
@ -109,36 +183,53 @@
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div class="flex items-center gap-4"> <!--
<Avatar user={auth.user} size={64} /> Das Bild steht neben Name und Adresse und reicht ueber beide
<div class="space-y-1"> Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt
<Label for="avatar">Bild</Label> unsichtbar darunter, angeklickt wird das Bild selbst — das
Label „Bild" ueber einem Dateifeld sagte nichts, was das Bild
nicht schon zeigt.
-->
<div class="flex items-start gap-4">
<label
for="avatar"
class="group relative shrink-0 cursor-pointer rounded-full ring-offset-2 ring-offset-card focus-within:ring-2 focus-within:ring-ring"
>
{#if avatarPreview}
<img
src={avatarPreview}
alt=""
class="size-20 rounded-full object-cover"
/>
{:else}
<Avatar user={auth.user} size={80} />
{/if}
<span
class="absolute inset-0 grid place-items-center rounded-full bg-black/55 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<Camera class="size-5" />
</span>
<input <input
id="avatar" id="avatar"
type="file" type="file"
accept="image/*" accept="image/*"
class="block w-full text-sm" class="sr-only"
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)} onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
/> />
<p class="text-xs text-muted-foreground"> </label>
Ohne Bild stehen deine Initialen da.
</p> <div class="min-w-0 flex-1 space-y-2">
<Input aria-label="Name" bind:value={name} placeholder="Wie du angezeigt wirst" />
<Input aria-label="E-Mail" value={auth.user?.email ?? ''} readonly disabled />
</div> </div>
</div> </div>
<div class="space-y-2"> <p class="text-xs text-muted-foreground">
<Label for="profile-name">Name</Label> Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" /> Team-Administration über PocketBase.
</div> </p>
<div class="space-y-2">
<Label for="profile-mail">E-Mail</Label>
<Input id="profile-mail" value={auth.user?.email ?? ''} readonly disabled />
<p class="text-xs text-muted-foreground">
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
Team-Administration über PocketBase.
</p>
</div>
{#if profileError} {#if profileError}
<p class="text-sm text-destructive">{profileError}</p> <p class="text-sm text-destructive">{profileError}</p>
@ -187,4 +278,43 @@
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
<!--
Eine eigene Karte, ganz unten: Was nicht rückgängig zu machen ist,
steht nicht zwischen Namensfeld und Passwortwechsel.
-->
<Card class="lg:col-span-2 border-destructive/30">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Trash2 class="size-4 text-destructive" />
Konto löschen
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
{#if ownedTeams.length > 0}
<p class="text-sm text-muted-foreground">
Du bist Owner von
{ownedTeams.map((t) => `„${t.name}"`).join(', ')}. Ein Team braucht
seinen Owner: Übergib es oder lösche es zuerst, danach lässt sich
dein Konto löschen.
</p>
<Button variant="outline" disabled>Konto löschen</Button>
{:else}
<p class="text-sm text-muted-foreground">
Dein Konto wird endgültig gelöscht und du wirst abgemeldet. Dein
Fahrer bleibt mit allen Zeiten und Teilnahmen im Kader stehen — nur
anmelden kannst du dich nicht mehr.
</p>
{#if deleteError}
<p class="text-sm text-destructive">{deleteError}</p>
{/if}
<Button variant="outline" onclick={deleteAccount} disabled={deletingAccount}>
<Trash2 class="size-4 mr-2 text-destructive" />
{deletingAccount ? 'Wird gelöscht …' : 'Konto löschen'}
</Button>
{/if}
</CardContent>
</Card>
</div> </div>