Skriv migrasjon 033 (e-post-preferanser per varseltype) Legg til send_notification_email() i app/email.py Bygg GET/PUT /notifications/email-prefs + koble e-post inn i create_notification() Bygg frontend: velg varseltyper for e-post i /account Scratch-verifiser hele løpet (preferanser + faktisk e-postutsending) Oppdater CLAUDE.md + rull ut mot ekte systemer (med bekreftelse) Live. E-post-fallback for varsler er ferdig, per-type valgbar av mottakeren selv: Ny seksjon "Varsler på e-post" i /account — fire avkrysningsbokser (Venneforespørsler/Runder/Resultater/Turneringer), trygg standard er ingen valgt (ingen e-post inntil du eksplisitt krysser av). E-post-utsendingen ligger ett sted (inni create_notification()), så alle nåværende og fremtidige varseltyper får e-post-støtte automatisk. 19/19 nye sjekker + full regresjon av fire eksisterende testsuiter (219 sjekker totalt) grønt. Migrasjon 033 kjørt mot ekte database (kun ny tabell), test_isolation.sql fortsatt 12/12, begge containere redeployet, teeoff.no upåvirket.
1418 lines
53 KiB
TypeScript
1418 lines
53 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,
|
|
Bell,
|
|
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.
|
|
// Kølle-bag (2026-07-24): fast, ikke-utvidbar liste -- MÅ matche
|
|
// BAG_CLUBS i app/routers/auth.py nøyaktig (backend validerer strengt).
|
|
const BAG_CLUBS = [
|
|
"Driver", "Mini-driver", "Driving iron", "3-wood", "4-wood", "5-wood",
|
|
"7-wood", "9-wood", "2-hybrid", "3-hybrid", "4-hybrid", "5-hybrid",
|
|
"1-iron", "2-iron", "3-iron", "4-iron", "5-iron", "6-iron", "7-iron",
|
|
"8-iron", "9-iron", "Pitching wedge", "Gap wedge", "Sand wedge",
|
|
"Lob wedge", "Ultra-lob wedge", "Chipper", "Putter",
|
|
] as const
|
|
const MAX_BAG_CLUBS = 14
|
|
|
|
// Land+hjemmeklubb (2026-07-25): "Land" er klargjøring for fremtidig
|
|
// flerspråklighet -- kun norske klubber finnes i teeoff ennå, så listen har
|
|
// bevisst kun ett valg foreløpig. Flere land legges til her når teeoff (eller
|
|
// en fremtidig tilsvarende kilde) faktisk har data for dem -- INGEN
|
|
// backend-endring trengs da, kun denne listen utvides.
|
|
const COUNTRIES = ["Norge"] as const
|
|
|
|
function CountryField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor={id} className="text-sm font-semibold">
|
|
Land
|
|
</Label>
|
|
<select
|
|
id={id}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
|
>
|
|
{COUNTRIES.map((c) => (
|
|
<option key={c} value={c}>
|
|
{c}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Hjemmeklubb (2026-07-25): søkbar liste mot teeoffs klubbregister --
|
|
// gjenbruker `/rounds/official-search` (allerede org-uavhengig, tilgjengelig
|
|
// for enhver innlogget bruker, ADR-033), samme mønster som bane-søket ved ny
|
|
// runde. Teeoff filtrerer allerede bort upubliserte/nedlagte anlegg
|
|
// server-side (`is_published`), så ingen egen filtrering trengs her.
|
|
type HomeClubOption = { slug: string; name: string; city: string | null; county: string | null }
|
|
|
|
function HomeClubField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) {
|
|
const [query, setQuery] = useState(value)
|
|
const [results, setResults] = useState<HomeClubOption[]>([])
|
|
const [open, setOpen] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
let cancelled = false
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, {
|
|
credentials: "include",
|
|
})
|
|
if (res.ok && !cancelled) setResults(await res.json())
|
|
} catch {
|
|
// Stille -- listen blir bare uendret, ingen kritisk feil å vise her.
|
|
}
|
|
}, 250)
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(timer)
|
|
}
|
|
}, [query, open])
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
}, [])
|
|
|
|
function selectClub(option: HomeClubOption) {
|
|
onChange(option.name)
|
|
setQuery(option.name)
|
|
setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative flex flex-col gap-1.5">
|
|
<Label htmlFor={id} className="text-sm font-semibold">
|
|
Hjemmeklubb
|
|
</Label>
|
|
<Input
|
|
id={id}
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
onChange(e.target.value)
|
|
setOpen(true)
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
placeholder="Søk etter klubb …"
|
|
autoComplete="off"
|
|
className="h-11 rounded-xl"
|
|
/>
|
|
{open && results.length > 0 && (
|
|
<ul className="absolute top-full z-20 mt-1 max-h-64 w-full overflow-auto rounded-xl border border-border bg-card shadow-md shadow-black/10">
|
|
{results.map((option) => (
|
|
<li key={option.slug}>
|
|
<button
|
|
type="button"
|
|
onClick={() => selectClub(option)}
|
|
className="flex min-h-11 w-full flex-col items-start justify-center px-3 py-1.5 text-left transition-colors hover:bg-accent/60"
|
|
>
|
|
<span className="text-sm font-semibold text-foreground">{option.name}</span>
|
|
{(option.city || option.county) && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{[option.city, option.county].filter(Boolean).join(", ")}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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
|
|
// Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet
|
|
// automatisk fra frittstående runder. Aldri direkte redigerbar.
|
|
computed_handicap_index: number | null
|
|
computed_handicap_index_updated_at: string | null
|
|
home_club: string | null
|
|
country: string | null
|
|
bio: string | null
|
|
mobile_country_code: string | null
|
|
mobile_number: string | null
|
|
bag_clubs: string[]
|
|
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} />
|
|
|
|
<NotificationEmailPrefsSection />
|
|
|
|
<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))
|
|
// Landfeltet er nå en nedtrekksliste (2026-07-25) -- default til det
|
|
// eneste tilgjengelige valget, samme "forhåndsutfylt fremfor tomt"-prinsipp
|
|
// som HCP-standarden 54 over.
|
|
const [country, setCountry] = useState(me.country ?? COUNTRIES[0])
|
|
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
|
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>
|
|
<CountryField id="ob-country" value={country} onChange={setCountry} />
|
|
<HomeClubField id="ob-home-club" value={homeClub} onChange={setHomeClub} />
|
|
<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 [country, setCountry] = useState(me.country ?? COUNTRIES[0])
|
|
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
|
const [bio, setBio] = useState(me.bio ?? "")
|
|
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
|
|
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
|
|
const [bagClubs, setBagClubs] = useState<string[]>(me.bag_clubs)
|
|
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(),
|
|
bag_clubs: bagClubs,
|
|
}),
|
|
})
|
|
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>
|
|
<CountryField id="country" value={country} onChange={setCountry} />
|
|
<HomeClubField id="home-club" value={homeClub} onChange={setHomeClub} />
|
|
<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>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<Label className="text-sm font-semibold">Køllebag</Label>
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{bagClubs.length}/{MAX_BAG_CLUBS} valgt
|
|
</span>
|
|
</div>
|
|
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
|
|
Velg køllene du spiller med (maks {MAX_BAG_CLUBS}). Disse blir tilgjengelige som
|
|
valg når du registrerer hvilken kølle du slo ut med i en runde.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
|
|
{BAG_CLUBS.map((club) => {
|
|
const checked = bagClubs.includes(club)
|
|
const disabled = !checked && bagClubs.length >= MAX_BAG_CLUBS
|
|
return (
|
|
<label
|
|
key={club}
|
|
className={`flex min-h-11 items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium ${
|
|
checked ? "border-primary bg-primary/10 text-foreground" : "border-border text-foreground"
|
|
} ${disabled ? "opacity-50" : "cursor-pointer"}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
setBagClubs((prev) =>
|
|
e.target.checked ? [...prev, club] : prev.filter((c) => c !== club),
|
|
)
|
|
}
|
|
className="size-4 shrink-0 rounded border-border"
|
|
/>
|
|
{club}
|
|
</label>
|
|
)
|
|
})}
|
|
</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>
|
|
|
|
<ActualHandicapCard me={me} onChanged={onChanged} />
|
|
<HandicapHistorySection reloadKey={me.handicap_index} />
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet automatisk
|
|
// fra frittstående runder (round.py sin _recompute_computed_handicap_index).
|
|
// Aldri direkte redigerbar -- kun "manuelt satt HCP" over er det. Denne
|
|
// kortet lar brukeren EKSPLISITT kopiere over verdien, ingen automatikk.
|
|
function ActualHandicapCard({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|
const [applying, setApplying] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function applyComputed() {
|
|
setApplying(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch("/auth/profile/handicap/apply-computed", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
})
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => null)
|
|
throw new Error(body?.detail?.message ?? "Klarte ikke å overføre HCP.")
|
|
}
|
|
onChanged()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
|
} finally {
|
|
setApplying(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
|
|
<span className="text-sm font-semibold text-foreground">Faktisk HCP (beregnet)</span>
|
|
{me.computed_handicap_index === null ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Ingen beregning ennå -- trengs minst 3 tellende frittstående runder.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
Basert på dine siste tellende runder (WHS Rule 5.2), sist beregnet{" "}
|
|
{me.computed_handicap_index_updated_at
|
|
? new Date(me.computed_handicap_index_updated_at).toLocaleDateString("no-NO", {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
})
|
|
: "nylig"}
|
|
.
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<span className="text-2xl font-extrabold tabular-nums text-foreground">
|
|
{me.computed_handicap_index}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={applying || me.computed_handicap_index === me.handicap_index}
|
|
onClick={applyComputed}
|
|
className="h-9 rounded-xl text-sm font-semibold"
|
|
>
|
|
{applying ? "Overfører …" : "Bruk som mitt HCP →"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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; source: "manual" | "computed" }[] | 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="flex items-center gap-2">
|
|
<span className="font-semibold tabular-nums text-foreground">{entry.handicap_index}</span>
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{entry.source === "computed" ? "beregnet" : "manuelt"}
|
|
</span>
|
|
</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>
|
|
)
|
|
}
|
|
|
|
// --- E-post-fallback for varsler (2026-07-28 oppfølging) --------------------
|
|
// Trygg standard: ingen typer valgt inntil brukeren selv velger inn --
|
|
// PUT /notifications/email-prefs er en FULL erstatning (samme mønster som
|
|
// venne-kategoriseringen), ikke en delvis PATCH.
|
|
const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: string }[] = [
|
|
{ value: "friend", label: "Venneforespørsler", description: "Noen sender deg en venneforespørsel, eller godtar din." },
|
|
{ value: "round", label: "Runder", description: "Du blir lagt til som medspiller, eller en venn starter en runde du kan følge." },
|
|
{ value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." },
|
|
{ value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
|
|
]
|
|
|
|
function NotificationEmailPrefsSection() {
|
|
const [types, setTypes] = useState<string[]>([])
|
|
const [loaded, setLoaded] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetch("/notifications/email-prefs", { credentials: "include" })
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((data: { types: string[] } | null) => {
|
|
if (data) setTypes(data.types)
|
|
setLoaded(true)
|
|
})
|
|
.catch(() => setLoaded(true))
|
|
}, [])
|
|
|
|
async function save(next: string[]) {
|
|
setTypes(next)
|
|
setSaving(true)
|
|
setError(null)
|
|
setSuccess(false)
|
|
try {
|
|
const res = await fetch("/notifications/email-prefs", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ types: next }),
|
|
})
|
|
if (!res.ok) throw new Error("Klarte ikke å lagre varselinnstillingene.")
|
|
setSuccess(true)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
|
} finally {
|
|
setSaving(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">
|
|
<Bell aria-hidden="true" className="size-5 text-primary" />
|
|
</div>
|
|
<h2 className="text-base font-bold text-foreground">Varsler på e-post</h2>
|
|
</div>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Du får alltid varsler i appen (klokke-ikonet). Velg her hvilke typer du i tillegg vil ha på
|
|
e-post -- ingen sendes med mindre du krysser av.
|
|
</p>
|
|
|
|
{loaded && (
|
|
<div className="flex flex-col gap-1.5">
|
|
{NOTIFICATION_TYPE_OPTIONS.map((opt) => {
|
|
const checked = types.includes(opt.value)
|
|
return (
|
|
<label
|
|
key={opt.value}
|
|
className={`flex min-h-11 items-start gap-3 rounded-xl border px-3 py-2.5 ${
|
|
checked ? "border-primary bg-primary/10" : "border-border"
|
|
} ${saving ? "opacity-70" : "cursor-pointer"}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={saving}
|
|
onChange={(e) =>
|
|
void save(e.target.checked ? [...types, opt.value] : types.filter((t) => t !== opt.value))
|
|
}
|
|
className="mt-0.5 size-4 shrink-0 rounded border-border"
|
|
/>
|
|
<span className="flex flex-col">
|
|
<span className="text-sm font-semibold text-foreground">{opt.label}</span>
|
|
<span className="text-xs text-muted-foreground text-pretty">{opt.description}</span>
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
{success && <p className="text-sm font-medium text-primary">Lagret.</p>}
|
|
</section>
|
|
)
|
|
}
|