teecup/frontend/components/account-settings.tsx
Erol Haagenrud 79c4406723 Update Todos
Skriv migrasjon 019 (app_user.country + bio)

Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap

Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx

Scratch-verifiser hele flyten

Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:

Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:

Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00

1102 lines
40 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 { useRouter } from "next/navigation"
import {
ArrowLeft,
Camera,
KeyRound,
Lock,
LogOut,
Mail,
Plus,
ShieldCheck,
ShieldOff,
Trash2,
User,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
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
country: string | null
bio: string | null
mobile_country_code: string | null
mobile_number: string | null
avatar_url: string | null
// 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse
// teller ikke). Se AccountSettings sin bruk under.
profile_complete: boolean
secondary_emails: { id: string; email: string }[]
}
export function AccountSettings() {
const router = useRouter()
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
// Obligatorisk profil-fullføring (2026-07-22): en ny/ufullstendig konto
// ser KUN dette skjemaet -- ingen tilgang til resten av kontosidene eller
// dashbordet før alle obligatoriske felt er utfylt. Se page.tsx og
// Dashboard for de to inngangspunktene som sender brukeren hit.
if (!me.profile_complete) {
return (
<ProfileOnboarding
me={me}
onComplete={() => router.replace("/dashboard")}
/>
)
}
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} />
<EmailSection email={me.email} />
<SecondaryEmailSection secondaryEmails={me.secondary_emails} 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>
)
}
// --- Obligatorisk profil-fullføring (2026-07-22) ----------------------------
// Vises i STEDET for resten av appen (dashbord, øvrige kontoseksjoner) helt
// til alle obligatoriske felt er utfylt -- se AccountSettings sin
// profile_complete-gren. Kun bilde og beskrivelse er valgfrie her.
function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void }) {
const router = useRouter()
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 ?? "")
// WHS-maksimum (54) er riktig utgangspunkt for en spiller uten offisiell
// HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske.
const [hcp, setHcp] = useState(me.handicap_index === null ? "54" : String(me.handicap_index))
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
const [country, setCountry] = useState(me.country ?? "")
const [bio, setBio] = useState(me.bio ?? "")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const valid =
firstName.trim() !== "" &&
lastName.trim() !== "" &&
birthDate !== "" &&
gender !== "" &&
hcp.trim() !== "" &&
homeClub.trim() !== "" &&
country.trim() !== ""
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid || saving) return
setSaving(true)
setError(null)
try {
const res = await fetch("/auth/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
first_name: firstName.trim(),
last_name: lastName.trim(),
birth_date: birthDate,
gender,
handicap_index: Number(hcp.replace(",", ".")),
home_club: homeClub.trim(),
country: country.trim(),
bio: bio.trim() === "" ? null : bio.trim(),
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.")
}
const updated: Me = await res.json()
if (updated.profile_complete) {
onComplete()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSaving(false)
}
}
async function handleLogout() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
}
}
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 justify-between gap-4 px-5 py-4">
<Wordmark compact />
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
<div className="flex flex-col gap-2 pb-6 text-center sm:text-left">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
Fullfør profilen din
</h1>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Før du kan komme videre trenger vi noen opplysninger om deg. Alt utenom beskrivelse
er obligatorisk. Du kan redigere alt dette senere under «Konto».
</p>
</div>
<form
onSubmit={handleSubmit}
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="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-first-name" className="text-sm font-semibold">
Fornavn
</Label>
<Input
id="ob-first-name"
autoFocus
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-last-name" className="text-sm font-semibold">
Etternavn
</Label>
<Input
id="ob-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="ob-birth-date" className="text-sm font-semibold">
Fødselsdato
</Label>
<Input
id="ob-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="ob-gender" className="text-sm font-semibold">
Kjønn
</Label>
<select
id="ob-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="">Velg </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="ob-hcp" className="text-sm font-semibold">
HCP
</Label>
<Input
id="ob-hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="h-11 rounded-xl"
/>
<p className="text-xs text-muted-foreground">
Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-home-club" className="text-sm font-semibold">
Hjemmeklubb
</Label>
<Input
id="ob-home-club"
value={homeClub}
onChange={(e) => setHomeClub(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-country" className="text-sm font-semibold">
Land
</Label>
<Input
id="ob-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="ob-bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<textarea
id="ob-bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={2}
placeholder="F.eks. Golf nut, running teeoff.no"
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<Button type="submit" disabled={!valid || saving} className="h-12 w-fit rounded-xl font-semibold">
{saving ? "Lagrer …" : "Fortsett"}
</Button>
</form>
</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 [country, setCountry] = useState(me.country ?? "")
const [bio, setBio] = useState(me.bio ?? "")
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
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(),
country: country.trim() === "" ? null : country.trim(),
bio: bio.trim() === "" ? null : bio.trim(),
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.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 className="flex flex-col gap-1.5">
<Label htmlFor="country" className="text-sm font-semibold">
Land
</Label>
<Input
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={2}
placeholder="F.eks. Golf nut, running teeoff.no"
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="mobile-number" className="text-sm font-semibold">
Mobil
</Label>
<div className="flex gap-2">
<Input
id="mobile-country-code"
aria-label="Landsnummer"
value={mobileCountryCode}
onChange={(e) => setMobileCountryCode(e.target.value)}
placeholder="+47"
className="h-11 w-20 shrink-0 rounded-xl text-center"
/>
<Input
id="mobile-number"
type="tel"
value={mobileNumber}
onChange={(e) => setMobileNumber(e.target.value)}
placeholder="912 34 567"
className="h-11 flex-1 rounded-xl"
/>
</div>
</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>
<HandicapHistorySection reloadKey={me.handicap_index} />
</section>
)
}
// HCP-historikk (ADR-031 sitt "naturlig neste steg"-punkt): en append-only
// logg bygget opp av selve PATCH-endepunktet (app/routers/auth.py) hver
// gang HCP-feltet faktisk endres til en tallverdi. `reloadKey` (gjeldende
// HCP) sørger for at listen hentes på nytt rett etter en lagring, uten en
// egen refetch-prop å tre gjennom fra ProfileSection.
function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) {
const [open, setOpen] = useState(false)
const [entries, setEntries] = useState<{ handicap_index: number; recorded_at: string }[] | null>(null)
useEffect(() => {
if (!open) return
let cancelled = false
fetch("/auth/profile/handicap-history", { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data) => {
if (!cancelled) setEntries(data)
})
.catch(() => {
if (!cancelled) setEntries([])
})
return () => {
cancelled = true
}
}, [open, reloadKey])
return (
<div className="border-t border-border pt-4">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
{open ? "Skjul HCP-historikk" : "Vis HCP-historikk"}
</button>
{open && (
<div className="mt-3">
{entries === null ? (
<p className="text-sm text-muted-foreground">Laster </p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-foreground text-pretty">
Ingen historikk ennå registreres automatisk neste gang du endrer HCP.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{[...entries].reverse().map((entry, i) => (
<li
key={`${entry.recorded_at}-${i}`}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-muted-foreground">
{new Date(entry.recorded_at).toLocaleDateString("no-NO", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
<span className="font-semibold tabular-nums text-foreground">{entry.handicap_index}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
// --- E-post (identifikatoren) -----------------------------------------------
// Bevisst IKKE en del av ProfileSection sin vanlige PATCH -- e-post er
// innloggings-identifikatoren, endring krever at den NYE adressen beviser
// eierskap først (se app/routers/auth.py sin request_email_change/
// confirm_email_change).
function EmailSection({ email }: { email: string }) {
const [editing, setEditing] = useState(false)
const [newEmail, setNewEmail] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/profile/email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ new_email: newEmail.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sende bekreftelseslenken.")
}
setSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
return (
<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">
<Mail aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">E-post</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Dette er identifikatoren du logger inn med: <span className="font-semibold text-foreground">{email}</span>
</p>
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
Sjekk innboksen til {newEmail.trim()} åpne lenken der for å fullføre byttet. Adressen
endres ikke før den er bekreftet.
</p>
) : editing ? (
<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-email" className="text-sm font-semibold">
Ny e-postadresse
</Label>
<Input
id="new-email"
type="email"
autoFocus
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
disabled={submitting || newEmail.trim() === ""}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{submitting ? "Sender …" : "Send bekreftelse"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setEditing(false)}
className="h-12 shrink-0 rounded-xl font-semibold"
>
Avbryt
</Button>
</div>
</form>
) : (
<Button
type="button"
variant="outline"
onClick={() => setEditing(true)}
className="h-11 w-fit rounded-xl font-semibold"
>
Endre e-post
</Button>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</section>
)
}
// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle
// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
// deretter brukes til innlogging i tillegg til hovedadressen. Ekte
// konto-sammenslåing (adressen tilhører allerede en annen konto) er
// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE.
function SecondaryEmailSection({
secondaryEmails,
onChanged,
}: {
secondaryEmails: { id: string; email: string }[]
onChanged: () => void
}) {
const [adding, setAdding] = useState(false)
const [newEmail, setNewEmail] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/secondary-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: newEmail.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sende bekreftelseslenken.")
}
setSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function handleRemove(id: string) {
setRemovingId(id)
try {
const res = await fetch(`/auth/secondary-email/${id}`, { method: "DELETE", credentials: "include" })
if (res.ok || res.status === 404) onChanged()
} finally {
setRemovingId(null)
}
}
return (
<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">
<Mail aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Andre e-postadresser</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Har du fått en turneringsinvitasjon en annen adresse enn {""}
{"hovedadressen din"}? Legg den til her, kan du logge inn med begge og turneringer/
data knyttet til den andre adressen dukker opp kontoen din.
</p>
{secondaryEmails.length > 0 && (
<ul className="flex flex-col gap-2">
{secondaryEmails.map((se) => (
<li
key={se.id}
className="flex items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2.5"
>
<span className="truncate text-sm font-semibold text-foreground">{se.email}</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={removingId === se.id}
onClick={() => handleRemove(se.id)}
className="size-8 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
aria-label={`Fjern ${se.email}`}
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
Sjekk innboksen til {newEmail.trim()} åpne lenken der for å bekrefte at du eier
adressen. Ingenting legges til før den er bekreftet.
</p>
) : adding ? (
<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="secondary-email" className="text-sm font-semibold">
Ny adresse
</Label>
<Input
id="secondary-email"
type="email"
autoFocus
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
disabled={submitting || newEmail.trim() === ""}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{submitting ? "Sender …" : "Send bekreftelse"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setAdding(false)}
className="h-12 shrink-0 rounded-xl font-semibold"
>
Avbryt
</Button>
</div>
</form>
) : (
<Button
type="button"
variant="outline"
onClick={() => setAdding(true)}
className="h-11 w-fit rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til adresse
</Button>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</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>
)
}