367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
|
|
"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, UserPlus, Users } 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} />}
|
||
|
|
|
||
|
|
<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}
|
||
|
|
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 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 (
|
||
|
|
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||
|
|
<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>
|
||
|
|
)
|
||
|
|
}
|