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.
821 lines
30 KiB
TypeScript
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 på en annen adresse enn {""}
|
|
{"hovedadressen din"}? Legg den til her, så kan du logge inn med begge — og turneringer/
|
|
data knyttet til den andre adressen dukker opp på 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>
|
|
)
|
|
}
|