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:
parent
b3d77dddbe
commit
041bed923b
1 changed files with 152 additions and 22 deletions
|
|
@ -5,16 +5,40 @@
|
|||
* also niemand für jemand anderen ändern.
|
||||
*/
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
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 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 profileMessage = $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 newPassword = $state('')
|
||||
let confirmPassword = $state('')
|
||||
|
|
@ -109,36 +183,53 @@
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar user={auth.user} size={64} />
|
||||
<div class="space-y-1">
|
||||
<Label for="avatar">Bild</Label>
|
||||
<!--
|
||||
Das Bild steht neben Name und Adresse und reicht ueber beide
|
||||
Zeilen. Es ist zugleich der Knopf: Das Dateifeld liegt
|
||||
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
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="block w-full text-sm"
|
||||
class="sr-only"
|
||||
onchange={(e) => (avatarFile = e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Ohne Bild stehen deine Initialen da.
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<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 class="space-y-2">
|
||||
<Label for="profile-name">Name</Label>
|
||||
<Input id="profile-name" bind:value={name} placeholder="Wie du angezeigt wirst" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Die E-Mail-Adresse ist der Anmeldename. Ändern kann sie nur die
|
||||
Team-Administration über PocketBase.
|
||||
</p>
|
||||
|
||||
{#if profileError}
|
||||
<p class="text-sm text-destructive">{profileError}</p>
|
||||
|
|
@ -187,4 +278,43 @@
|
|||
</Button>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue