teecup/frontend/components/account-settings.tsx
Erol Haagenrud 1a86be3f83 Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)

Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match

Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup

Widen tournaments.py: list_sessions/list_teams/concede_tournament

Widen courses.py: list_holes

Widen messaging.py: team chat REST-endepunkter (list/send/delete)

Legge til my_session_id/my_match_id i /auth/me sin my_tournaments

Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»

Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)

Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag

Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden

Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017)

Designe, bygge og scratch-verifisere HCP-historikk (migrasjon 018)
HCP-historikk er bygget og scratch-verifisert (18/18 sjekker, test_isolation.sql fortsatt 12/12).

Hva er bygget:

Migrasjon 018_handicap_history.sql — ny append-only-tabell handicap_history, kun for den personlige profilens HCP (ikke lagenes frosne handicap_index_snapshot, som er urørt, ADR-007).
Backend: PATCH /auth/profile logger nå automatisk en ny rad når HCP faktisk endres til en tallverdi — ikke ved ren nullstilling, og ikke ved gjentatt lagring av samme verdi (sjekket eksplisitt mot forrige verdi først). Ny GET /auth/profile/handicap-history.
Frontend: en «Vis HCP-historikk»-lenke i /account sin profilseksjon, viser dato + verdi-liste, oppdateres automatisk etter lagring.
Verifisert grundig: ingen duplikat ved gjentatt lagring av uendret verdi, korrekt logging ved faktisk endring, ingen logg ved nullstilling, ny logg ved gjeninnsetting etter nullstilling, kronologisk rekkefølge riktig, og full isolasjon mellom to brukeres historikk.
2026-07-22 06:31:11 +02:00

821 lines
30 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, 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 { 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
mobile_country_code: string | null
mobile_number: string | null
avatar_url: string | null
secondary_emails: { id: string; email: string }[]
}
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} />
<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>
)
}
// --- 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 [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(),
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 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>
)
}