226 lines
8.9 KiB
TypeScript
226 lines
8.9 KiB
TypeScript
|
|
"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 nå 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 så 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>
|
||
|
|
)
|
||
|
|
}
|