teecup/frontend/components/org-members.tsx

393 lines
15 KiB
TypeScript
Raw Permalink Normal View History

"use client"
// Medlemsstyring (ADR-022): invitere, endre rolle, fjerne/forlate. Rolle-
// grensene håndheves også server-side (app/routers/organizations.py) --
// UI-et her skjuler bare handlinger som uansett ville blitt avvist, for en
// tydeligere opplevelse, ikke som eneste håndhevelse.
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users, UsersRound } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
type Role = "owner" | "admin" | "member"
type Membership = {
id: string
user_id: string
email: string
display_name: string
role: Role
}
type Invitation = {
id: string
email: string
role: Role
expires_at: string
}
const ROLE_LABELS: Record<Role, string> = { owner: "Eier", admin: "Administrator", member: "Medlem" }
export function OrgMembers({ organizationId, orgName }: { organizationId: string; orgName: string }) {
const [myUserId, setMyUserId] = useState<string | null>(null)
const [myRole, setMyRole] = useState<Role | null>(null)
const [members, setMembers] = useState<Membership[]>([])
const [invitations, setInvitations] = useState<Invitation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadAll() {
try {
const [meRes, membersRes, invitationsRes] = await Promise.all([
fetch("/auth/me", { credentials: "include" }),
fetch(`/orgs/${organizationId}/memberships`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/invitations`, { credentials: "include" }),
])
if (meRes.ok) {
const me: { id: string; organizations: { organization_id: string; role: Role }[] } = await meRes.json()
setMyUserId(me.id)
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
}
if (membersRes.ok) setMembers(await membersRes.json())
if (invitationsRes.ok) setInvitations(await invitationsRes.json())
} catch {
setError("Klarte ikke å laste medlemmer. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId])
async function inviteMember(email: string, role: Role) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/invitations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, role }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sende invitasjonen.")
}
const created: Invitation = await res.json()
setInvitations((prev) => [created, ...prev])
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å sende invitasjonen.")
}
}
async function revokeInvitation(id: string) {
const res = await fetch(`/orgs/${organizationId}/invitations/${id}`, {
method: "DELETE",
credentials: "include",
})
if (res.ok || res.status === 204) {
setInvitations((prev) => prev.filter((i) => i.id !== id))
}
}
async function changeRole(membershipId: string, role: Role) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/memberships/${membershipId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ role }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å endre rollen.")
}
const updated: Membership = await res.json()
setMembers((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å endre rollen.")
}
}
async function removeMember(membershipId: string, isSelf: boolean) {
const confirmMsg = isSelf
? "Er du sikker på at du vil forlate organisasjonen?"
: "Er du sikker på at du vil fjerne dette medlemmet?"
if (!confirm(confirmMsg)) return
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/memberships/${membershipId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å fjerne medlemmet.")
}
setMembers((prev) => prev.filter((m) => m.id !== membershipId))
if (isSelf) window.location.href = "/dashboard"
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å fjerne medlemmet.")
}
}
const canInvite = myRole === "owner" || myRole === "admin"
const inviteRoleOptions: Role[] = myRole === "owner" ? ["member", "admin", "owner"] : ["member"]
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>
)
}
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">
Medlemmer
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{orgName}</h1>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
<Link
href={`/organizations/${organizationId}/players?name=${encodeURIComponent(orgName)}`}
className="mt-6 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<UsersRound aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-foreground">Spillerpool</span>
<span className="text-xs text-muted-foreground">Se og rediger alle spillere organisasjonen har registrert</span>
</div>
</Link>
<Link
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
className="mt-3 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-foreground">Order of Merit</span>
<span className="text-xs text-muted-foreground">Sesong-sammenlagt rangering tvers av turneringer</span>
</div>
</Link>
<section className="mt-6 flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<Users aria-hidden="true" className="size-4" />
Medlemmer ({members.length})
</h2>
{members.map((m) => (
<MemberRow
key={m.id}
member={m}
isSelf={m.user_id === myUserId}
canManage={myRole === "owner"}
onChangeRole={changeRole}
onRemove={removeMember}
/>
))}
</section>
{invitations.length > 0 && (
<section className="mt-6 flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<Mail aria-hidden="true" className="size-4" />
Ventende invitasjoner ({invitations.length})
</h2>
{invitations.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between gap-3 rounded-2xl border border-dashed border-border bg-card/50 p-4"
>
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{inv.email}</span>
<span className="text-xs text-muted-foreground">Invitert som {ROLE_LABELS[inv.role]}</span>
</div>
{(myRole === "owner" || (myRole === "admin" && inv.role === "member")) && (
<button
type="button"
onClick={() => void revokeInvitation(inv.id)}
aria-label={`Opphev invitasjon til ${inv.email}`}
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
)}
</div>
))}
</section>
)}
</main>
</div>
)
}
function InviteForm({
roleOptions,
onInvite,
}: {
roleOptions: Role[]
onInvite: (email: string, role: Role) => void
}) {
const [email, setEmail] = useState("")
const [role, setRole] = useState<Role>(roleOptions[0])
const [sending, setSending] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email.trim() || sending) return
setSending(true)
try {
await onInvite(email.trim(), role)
setEmail("")
} finally {
setSending(false)
}
}
return (
<form
onSubmit={handleSubmit}
2026-07-29 12:44:51 +02:00
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end sm:p-5"
>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="invite-email" className="text-sm font-semibold">
Inviter noen
</Label>
<Input
id="invite-email"
type="email"
placeholder="epost@eksempel.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="invite-role" className="text-sm font-semibold">
Rolle
</Label>
<select
id="invite-role"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{roleOptions.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</div>
<Button type="submit" disabled={!email.trim() || sending} className="h-11 shrink-0 rounded-xl font-semibold">
<UserPlus aria-hidden="true" className="size-4" />
{sending ? "Sender …" : "Inviter"}
</Button>
</form>
)
}
function MemberRow({
member,
isSelf,
canManage,
onChangeRole,
onRemove,
}: {
member: Membership
isSelf: boolean
canManage: boolean
onChangeRole: (id: string, role: Role) => void
onRemove: (id: string, isSelf: boolean) => void
}) {
// Selvbetjent nedgradering (aldri oppgradering, se app/routers/organizations.py)
// -- viser kun rollene som faktisk er lavere enn nåværende for egen rad når
// brukeren ikke er owner (owner kan endre fritt).
const rank: Record<Role, number> = { member: 0, admin: 1, owner: 2 }
const roleOptionsForRow: Role[] = canManage
? ["owner", "admin", "member"]
: isSelf
? (["owner", "admin", "member"] as Role[]).filter((r) => rank[r] <= rank[member.role])
: []
const canEditRole = canManage || (isSelf && roleOptionsForRow.length > 1)
const canRemove = canManage || isSelf
return (
2026-07-29 12:44:51 +02:00
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold text-foreground">
{member.display_name}
{isSelf && <span className="ml-1.5 text-xs font-medium text-muted-foreground">(deg)</span>}
</span>
<span className="truncate text-xs text-muted-foreground">{member.email}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{canEditRole ? (
<select
value={member.role}
onChange={(e) => onChangeRole(member.id, e.target.value as Role)}
className="h-9 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{roleOptionsForRow.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
) : (
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
{ROLE_LABELS[member.role]}
</span>
)}
{canRemove && (
<button
type="button"
onClick={() => onRemove(member.id, isSelf)}
aria-label={isSelf ? "Forlat organisasjonen" : `Fjern ${member.display_name}`}
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
)}
</div>
</div>
)
}