teecup/frontend/components/account-merge-confirm-form.tsx

226 lines
8.9 KiB
TypeScript
Raw Permalink Normal View History

"use client"
// Kontosammenslåing ("Del 2", ADR-080) -- bekreftelsessiden lenken sendt til
// MÅL-kontoens (taperens) e-post peker til. INGEN sesjon kreves (kan åpnes
// på en annen enhet enn den som ba om sammenslåingen, samme prinsipp som
// e-postbytte-bekreftelse) -- selve token-en ER beviset på eierskap.
//
// To steg, ALDRI automatisk: 1) hent en FERSK forhåndsvisning ved lasting
// (les-only, konsumerer ikke token), 2) krev et eksplisitt "Bekreft
// sammenslåing"-trykk før selve utførelsen -- en lenke skal aldri kunne
// utløse en irreversibel handling bare ved å bli åpnet.
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, CheckCircle2, Merge, XCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
type Status = "loading" | "ready" | "confirming" | "success" | "error"
type ApiOrgMembershipPreview = {
organization_id: string
organization_name: string
keeper_role: string | null
loser_role: string | null
resulting_role: string
}
type ApiOrgPlayerPreview = {
organization_id: string
organization_name: string
keeper_has_player: boolean
loser_has_player: boolean
colliding_registrations: number
}
type ApiMergePreview = {
keeper_id: string
keeper_display_name: string
keeper_email: string | null
loser_id: string
loser_display_name: string
loser_email: string | null
organization_memberships: ApiOrgMembershipPreview[]
player_links: ApiOrgPlayerPreview[]
friend_count: number
shared_friend_count: number
were_friends_with_each_other: boolean
round_count: number
secondary_email_count: number
}
const ROLE_LABELS: Record<string, string> = { owner: "eier", admin: "administrator", member: "medlem" }
export function AccountMergeConfirmForm({ token }: { token: string }) {
const [status, setStatus] = useState<Status>("loading")
const [preview, setPreview] = useState<ApiMergePreview | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/auth/account-merge/token/${token}`, { credentials: "include" })
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Lenken er ugyldig, brukt eller utløpt.")
}
const data: ApiMergePreview = await res.json()
if (!cancelled) {
setPreview(data)
setStatus("ready")
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
setStatus("error")
}
}
}
void load()
return () => {
cancelled = true
}
}, [token])
async function handleConfirm() {
setStatus("confirming")
setError(null)
try {
const res = await fetch("/auth/account-merge/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ token }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å slå sammen kontoene.")
}
setStatus("success")
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
setStatus("ready")
}
}
if (status === "loading") {
return (
<div className="flex w-full flex-col items-center gap-4 rounded-3xl border border-border bg-card p-8 text-center shadow-lg shadow-black/5">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<p className="text-sm text-muted-foreground" role="status">
Henter forhåndsvisning
</p>
</div>
)
}
if (status === "success") {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Kontoene er slått sammen</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alt er flyttet over. Denne kontoen finnes ikke lenger -- logg inn med den kontoen du
slo sammen til.
</p>
</div>
<Link href="/logg-inn" className="text-sm font-semibold text-primary transition-colors hover:text-primary/80">
Til innlogging
</Link>
</div>
)
}
if (status === "error" || !preview) {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<div className="flex size-16 items-center justify-center rounded-2xl bg-destructive/15">
<XCircle aria-hidden="true" className="size-8 text-destructive" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Kunne ikke bekrefte</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{error}</p>
</div>
<Link href="/account" className="text-sm font-semibold text-primary transition-colors hover:text-primary/80">
Til kontoinnstillinger
</Link>
</div>
)
}
return (
<div className="flex w-full flex-col gap-5 rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Merge aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-extrabold tracking-tight text-balance">Bekreft kontosammenslåing</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{preview.keeper_display_name}
{preview.keeper_email ? ` (${preview.keeper_email})` : ""} har bedt om å slå denne kontoen (
{preview.loser_display_name}
{preview.loser_email ? `, ${preview.loser_email}` : ""}) sammen med sin.
</p>
</div>
<ul className="flex flex-col gap-1.5 rounded-2xl border border-border bg-background p-3 text-sm text-foreground">
{preview.organization_memberships.map((m) => (
<li key={m.organization_id}>
{m.organization_name}: blir {ROLE_LABELS[m.resulting_role] ?? m.resulting_role}
</li>
))}
{preview.player_links.map((p) => (
<li key={p.organization_id}>
{p.organization_name}: spiller-koblingen slås sammen
{p.colliding_registrations > 0
? ` (${p.colliding_registrations} dobbeltregistrering${p.colliding_registrations === 1 ? "" : "er"} ryddes opp)`
: ""}
</li>
))}
{preview.round_count > 0 && <li>{preview.round_count} runde{preview.round_count === 1 ? "" : "r"} flyttes over</li>}
{preview.friend_count > 0 && (
<li>
{preview.friend_count} venn{preview.friend_count === 1 ? "" : "er"} flyttes over
{preview.shared_friend_count > 0 ? ` (${preview.shared_friend_count} felles, slås sammen)` : ""}
</li>
)}
{preview.were_friends_with_each_other && <li>Dere var venner med hverandre -- fjernes ved sammenslåing</li>}
{preview.secondary_email_count > 0 && (
<li>
{preview.secondary_email_count} ekstra e-postadresse{preview.secondary_email_count === 1 ? "" : "r"} flyttes over
</li>
)}
</ul>
<div className="flex flex-col gap-3 rounded-2xl border border-destructive/40 bg-destructive/5 p-4">
<div className="flex items-start gap-2">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-destructive" />
<p className="text-sm font-semibold text-foreground text-pretty">
Dette kan ikke angres. Denne kontoen ({preview.loser_display_name}) slettes helt snart
du bekrefter.
</p>
</div>
<Button
type="button"
variant="destructive"
disabled={status === "confirming"}
onClick={handleConfirm}
className="min-h-12 w-full justify-center gap-2 rounded-xl text-base font-bold"
>
<Merge aria-hidden="true" className="size-5" />
{status === "confirming" ? "Slår sammen …" : "Bekreft sammenslåing"}
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive text-pretty">{error}</p>}
</div>
)
}