Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
"use client"
|
|
|
|
// Kontoinnstillinger (ADR-021): sett/fjern passord (valgfritt tillegg til
|
|
// magic-link), aktiver/deaktiver 2FA. Frivillig 2FA-oppsett herfra bruker
|
|
// SAMME TwoFactorSetupForm som den tvungne flyten rett etter innlogging
|
|
// (forced=false her -- kun tekst-copyen skiller de to).
|
|
|
|
import type React from "react"
|
|
import { useEffect, useRef, useState } from "react"
|
|
import Link from "next/link"
|
|
import { ArrowLeft, Camera, KeyRound, Lock, ShieldCheck, ShieldOff, User, X } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
|
|
|
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
|
// org-scopede `player`-radene organisatorer administrerer.
|
|
type Me = {
|
|
id: string
|
|
email: string
|
|
display_name: string
|
|
has_password: boolean
|
|
two_factor_method: "totp" | "email" | null
|
|
first_name: string | null
|
|
last_name: string | null
|
|
birth_date: string | null
|
|
gender: "m" | "f" | "x" | null
|
|
handicap_index: number | null
|
|
home_club: string | null
|
|
avatar_url: string | null
|
|
}
|
|
|
|
export function AccountSettings() {
|
|
const [me, setMe] = useState<Me | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [settingUp2fa, setSettingUp2fa] = useState(false)
|
|
|
|
async function loadMe() {
|
|
try {
|
|
const res = await fetch("/auth/me", { credentials: "include" })
|
|
if (res.ok) setMe(await res.json())
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadMe()
|
|
}, [])
|
|
|
|
async function handleDisable2fa() {
|
|
if (!confirm("Er du sikker på at du vil slå av topartsautentisering?")) return
|
|
const res = await fetch("/auth/2fa/disable", { method: "POST", credentials: "include" })
|
|
if (res.ok) void loadMe()
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
|
<div
|
|
aria-hidden="true"
|
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!me) return null
|
|
|
|
return (
|
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
|
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
|
<Link
|
|
href="/dashboard"
|
|
aria-label="Tilbake til dashbord"
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
|
>
|
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
|
</Link>
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Konto</span>
|
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{me.email}</h1>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
|
{settingUp2fa ? (
|
|
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm shadow-black/5 sm:p-8">
|
|
<TwoFactorSetupForm
|
|
forced={false}
|
|
onSuccess={() => {
|
|
setSettingUp2fa(false)
|
|
void loadMe()
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-6">
|
|
<ProfileSection me={me} onChanged={loadMe} />
|
|
|
|
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
|
|
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
|
<ShieldCheck aria-hidden="true" className="size-5 text-primary" />
|
|
</div>
|
|
<h2 className="text-base font-bold text-foreground">Topartsautentisering (2FA)</h2>
|
|
</div>
|
|
|
|
{me.two_factor_method ? (
|
|
<>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Aktivert med{" "}
|
|
<span className="font-semibold text-foreground">
|
|
{me.two_factor_method === "totp" ? "autentisator-app" : "engangskode på e-post"}
|
|
</span>
|
|
.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleDisable2fa}
|
|
className="h-12 w-fit rounded-2xl font-semibold text-destructive hover:text-destructive"
|
|
>
|
|
<ShieldOff aria-hidden="true" className="size-4" />
|
|
Slå av 2FA
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Ikke aktivert. Anbefales for organisasjonseiere/administratorer -- da vil du
|
|
bli bedt om å sette det opp ved neste innlogging uansett.
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
onClick={() => setSettingUp2fa(true)}
|
|
className="h-12 w-fit rounded-2xl font-semibold"
|
|
>
|
|
<KeyRound aria-hidden="true" className="size-4" />
|
|
Sett opp 2FA
|
|
</Button>
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Personlig profil (ADR-031) ---------------------------------------------
|
|
|
|
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|
const [firstName, setFirstName] = useState(me.first_name ?? "")
|
|
const [lastName, setLastName] = useState(me.last_name ?? "")
|
|
const [birthDate, setBirthDate] = useState(me.birth_date ?? "")
|
|
const [gender, setGender] = useState(me.gender ?? "")
|
|
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
|
|
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
|
const [saving, setSaving] = useState(false)
|
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setSaving(true)
|
|
setError(null)
|
|
setSuccess(false)
|
|
try {
|
|
const res = await fetch("/auth/profile", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
first_name: firstName.trim() === "" ? null : firstName.trim(),
|
|
last_name: lastName.trim() === "" ? null : lastName.trim(),
|
|
birth_date: birthDate === "" ? null : birthDate,
|
|
gender: gender === "" ? null : gender,
|
|
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
|
|
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
|
|
}),
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => null)
|
|
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.")
|
|
}
|
|
setSuccess(true)
|
|
onChanged()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleAvatarSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setUploadingAvatar(true)
|
|
setError(null)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append("file", file)
|
|
const res = await fetch("/auth/profile/avatar", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
body: formData,
|
|
})
|
|
if (!res.ok) throw new Error("Klarte ikke å laste opp profilbildet.")
|
|
onChanged()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Klarte ikke å laste opp profilbildet.")
|
|
} finally {
|
|
setUploadingAvatar(false)
|
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
|
}
|
|
}
|
|
|
|
async function handleRemoveAvatar() {
|
|
setUploadingAvatar(true)
|
|
try {
|
|
const res = await fetch("/auth/profile/avatar", { method: "DELETE", credentials: "include" })
|
|
if (res.ok) onChanged()
|
|
} finally {
|
|
setUploadingAvatar(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
|
<User aria-hidden="true" className="size-5 text-primary" />
|
|
</div>
|
|
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary">
|
|
{me.avatar_url ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={me.avatar_url} alt="" className="size-full object-cover" />
|
|
) : (
|
|
<User aria-hidden="true" className="size-7 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
className="hidden"
|
|
onChange={handleAvatarSelected}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={uploadingAvatar}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="h-9 rounded-xl font-semibold"
|
|
>
|
|
<Camera aria-hidden="true" className="size-4" />
|
|
{me.avatar_url ? "Bytt bilde" : "Last opp bilde"}
|
|
</Button>
|
|
{me.avatar_url && (
|
|
<button
|
|
type="button"
|
|
onClick={handleRemoveAvatar}
|
|
disabled={uploadingAvatar}
|
|
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-destructive"
|
|
>
|
|
<X aria-hidden="true" className="size-3" />
|
|
Fjern bilde
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="first-name" className="text-sm font-semibold">
|
|
Fornavn
|
|
</Label>
|
|
<Input
|
|
id="first-name"
|
|
value={firstName}
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
className="h-11 rounded-xl"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="last-name" className="text-sm font-semibold">
|
|
Etternavn
|
|
</Label>
|
|
<Input
|
|
id="last-name"
|
|
value={lastName}
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
className="h-11 rounded-xl"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="birth-date" className="text-sm font-semibold">
|
|
Fødselsdato
|
|
</Label>
|
|
<Input
|
|
id="birth-date"
|
|
type="date"
|
|
value={birthDate}
|
|
onChange={(e) => setBirthDate(e.target.value)}
|
|
className="h-11 rounded-xl"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="gender" className="text-sm font-semibold">
|
|
Kjønn
|
|
</Label>
|
|
<select
|
|
id="gender"
|
|
value={gender}
|
|
onChange={(e) => setGender(e.target.value)}
|
|
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
|
>
|
|
<option value="">Ikke satt</option>
|
|
<option value="f">Dame</option>
|
|
<option value="m">Herre</option>
|
|
<option value="x">Annet</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="hcp" className="text-sm font-semibold">
|
|
HCP
|
|
</Label>
|
|
<Input
|
|
id="hcp"
|
|
inputMode="decimal"
|
|
value={hcp}
|
|
onChange={(e) => setHcp(e.target.value)}
|
|
className="h-11 rounded-xl"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="home-club" className="text-sm font-semibold">
|
|
Hjemmeklubb
|
|
</Label>
|
|
<Input
|
|
id="home-club"
|
|
value={homeClub}
|
|
onChange={(e) => setHomeClub(e.target.value)}
|
|
className="h-11 rounded-xl"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}
|
|
|
|
<Button type="submit" disabled={saving} className="h-11 w-fit rounded-xl font-semibold">
|
|
{saving ? "Lagrer …" : "Lagre profil"}
|
|
</Button>
|
|
</form>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
|
|
const [password, setPassword] = useState("")
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState(false)
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (password.length < 8 || submitting) return
|
|
setSubmitting(true)
|
|
setError(null)
|
|
setSuccess(false)
|
|
try {
|
|
const res = await fetch("/auth/set-password", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ password }),
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => null)
|
|
throw new Error(body?.detail?.message ?? "Klarte ikke å sette passordet.")
|
|
}
|
|
setPassword("")
|
|
setSuccess(true)
|
|
onChanged()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleRemove() {
|
|
if (!confirm("Fjerne passordet? Du kan fortsatt logge inn med magic-link.")) return
|
|
const res = await fetch("/auth/remove-password", { method: "POST", credentials: "include" })
|
|
if (res.ok) onChanged()
|
|
}
|
|
|
|
return (
|
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
|
<Lock aria-hidden="true" className="size-5 text-primary" />
|
|
</div>
|
|
<h2 className="text-base font-bold text-foreground">Passord</h2>
|
|
</div>
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
{hasPassword
|
|
? "Du har et passord satt. Du kan fortsatt logge inn med magic-link når som helst."
|
|
: "Ikke satt -- du logger i dag kun inn med magic-link. Passord er et valgfritt tillegg, aldri påkrevd."}
|
|
</p>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
<Label htmlFor="new-password" className="text-sm font-semibold">
|
|
{hasPassword ? "Nytt passord" : "Sett et passord"}
|
|
</Label>
|
|
<Input
|
|
id="new-password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
placeholder="Minst 8 tegn"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="h-12 rounded-xl"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
disabled={password.length < 8 || submitting}
|
|
className="h-12 shrink-0 rounded-xl font-semibold"
|
|
>
|
|
{submitting ? "Lagrer …" : hasPassword ? "Bytt passord" : "Sett passord"}
|
|
</Button>
|
|
</form>
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
{success && <p className="text-sm font-medium text-primary">Passordet er oppdatert.</p>}
|
|
|
|
{hasPassword && (
|
|
<button
|
|
type="button"
|
|
onClick={handleRemove}
|
|
className="w-fit text-sm font-semibold text-muted-foreground transition-colors hover:text-destructive"
|
|
>
|
|
Fjern passordet
|
|
</button>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|