236 lines
8.5 KiB
TypeScript
236 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
interface OrgMember {
|
|
userId: string;
|
|
email: string;
|
|
displayName: string;
|
|
givenName: string;
|
|
familyName: string;
|
|
roles: string[];
|
|
authorizationId: string;
|
|
}
|
|
|
|
interface Props {
|
|
initialMembers: OrgMember[];
|
|
currentUserId: string;
|
|
/**
|
|
* Whether the viewing user can change other members' roles. True only
|
|
* for customer owners. Server enforces this independently — this prop
|
|
* is purely UX (don't render the control if the action would 403).
|
|
*/
|
|
canEditRoles: boolean;
|
|
}
|
|
|
|
type RoleOption = "owner" | "user";
|
|
|
|
/**
|
|
* TeamList — renders the org's members. Refreshes after invites by
|
|
* polling the API; the InviteForm broadcasts a `team:refresh` window
|
|
* event after a successful invite so the list updates immediately
|
|
* rather than waiting for the next reload.
|
|
*
|
|
* Slice 7 + Bug 25: owners can change other members' roles inline.
|
|
* Clicking the "Change role" button on a row swaps the badge for a
|
|
* dropdown + Save/Cancel pair. We deliberately don't use a modal —
|
|
* the change is a single-field edit and the user already sees the row
|
|
* context, so inline is faster.
|
|
*
|
|
* Self-row never shows the editor (server enforces too). Last-owner
|
|
* demotion is enforced server-side; we surface the resulting 409 as a
|
|
* row-local error rather than pre-validating client-side, because the
|
|
* client doesn't know the org's full owner count without an extra
|
|
* round trip.
|
|
*/
|
|
export function TeamList({
|
|
initialMembers,
|
|
currentUserId,
|
|
canEditRoles,
|
|
}: Props) {
|
|
const t = useTranslations("team");
|
|
const [members, setMembers] = useState<OrgMember[]>(initialMembers);
|
|
|
|
// Per-row editor state. `editingId` is the userId currently being
|
|
// edited (only one at a time). `pendingRole` is the dropdown value.
|
|
// `rowError` carries server-rejection messages keyed by userId.
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [pendingRole, setPendingRole] = useState<RoleOption>("user");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [rowError, setRowError] = useState<Record<string, string>>({});
|
|
|
|
useEffect(() => {
|
|
function refresh() {
|
|
fetch("/api/team")
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((data) => {
|
|
if (data?.members) setMembers(data.members);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
window.addEventListener("team:refresh", refresh);
|
|
return () => window.removeEventListener("team:refresh", refresh);
|
|
}, []);
|
|
|
|
function startEdit(m: OrgMember) {
|
|
const current = (m.roles[0] === "owner" ? "owner" : "user") as RoleOption;
|
|
setEditingId(m.userId);
|
|
setPendingRole(current);
|
|
setRowError((e) => ({ ...e, [m.userId]: "" }));
|
|
}
|
|
|
|
function cancelEdit() {
|
|
setEditingId(null);
|
|
setSubmitting(false);
|
|
}
|
|
|
|
async function saveEdit(m: OrgMember) {
|
|
setSubmitting(true);
|
|
setRowError((e) => ({ ...e, [m.userId]: "" }));
|
|
try {
|
|
const res = await fetch(
|
|
`/api/team/${encodeURIComponent(m.userId)}/role`,
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ role: pendingRole }),
|
|
}
|
|
);
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error(data.error || t("roleUpdateFailed"));
|
|
}
|
|
// Optimistic update — replace the row's roles locally rather than
|
|
// re-fetching the whole list. The list will eventually re-fetch
|
|
// on the next `team:refresh` event anyway.
|
|
setMembers((prev) =>
|
|
prev.map((x) =>
|
|
x.userId === m.userId ? { ...x, roles: [pendingRole] } : x
|
|
)
|
|
);
|
|
setEditingId(null);
|
|
} catch (err: any) {
|
|
setRowError((e) => ({ ...e, [m.userId]: err.message }));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
if (members.length === 0) {
|
|
return (
|
|
<div className="text-sm text-text-secondary text-center py-6 border border-border rounded-xl bg-surface-1">
|
|
{t("noMembers")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
|
<ul className="divide-y divide-border">
|
|
{members.map((m) => {
|
|
const isSelf = m.userId === currentUserId;
|
|
const isEditing = editingId === m.userId;
|
|
// Hide editor for self even when the viewer is an owner —
|
|
// self-demotion is server-blocked and offering it as a UI
|
|
// affordance would just produce errors.
|
|
const showEditor = canEditRoles && !isSelf;
|
|
const err = rowError[m.userId];
|
|
|
|
return (
|
|
<li
|
|
key={m.userId}
|
|
className="px-5 py-3 flex items-center justify-between gap-4"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-text-primary truncate">
|
|
{m.displayName || m.email}
|
|
</span>
|
|
{isSelf && (
|
|
<span className="text-[10px] uppercase tracking-wider text-accent">
|
|
{t("you")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-text-muted truncate font-mono">
|
|
{m.email}
|
|
</div>
|
|
{err && (
|
|
<div className="text-xs text-red-400 mt-1">{err}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{isEditing ? (
|
|
<>
|
|
<select
|
|
value={pendingRole}
|
|
onChange={(e) =>
|
|
setPendingRole(e.target.value as RoleOption)
|
|
}
|
|
disabled={submitting}
|
|
className="text-xs bg-surface-2 border border-border rounded-md px-2 py-1 text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent"
|
|
>
|
|
<option value="user">{t("roleUser")}</option>
|
|
<option value="owner">{t("roleOwner")}</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => saveEdit(m)}
|
|
disabled={submitting || !m.authorizationId}
|
|
className="text-xs px-2.5 py-1 rounded-md bg-accent text-surface-0 hover:bg-accent-dim transition-colors disabled:opacity-50"
|
|
>
|
|
{t("save")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={cancelEdit}
|
|
disabled={submitting}
|
|
className="text-xs px-2.5 py-1 rounded-md border border-border text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
{t("cancel")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-wrap gap-1.5 justify-end">
|
|
{m.roles.length === 0 && (
|
|
<span className="text-[10px] uppercase tracking-wider text-text-muted bg-surface-3 px-2 py-0.5 rounded-full">
|
|
{t("noRole")}
|
|
</span>
|
|
)}
|
|
{m.roles.map((r) => (
|
|
<span
|
|
key={r}
|
|
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full ${
|
|
r === "owner"
|
|
? "bg-accent/15 text-accent border border-accent/20"
|
|
: "bg-surface-3 text-text-secondary border border-border"
|
|
}`}
|
|
>
|
|
{r}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{showEditor && (
|
|
<button
|
|
type="button"
|
|
onClick={() => startEdit(m)}
|
|
title={t("changeRole")}
|
|
className="text-xs text-text-muted hover:text-text-primary px-2 py-1 rounded-md transition-colors"
|
|
>
|
|
{t("changeRole")}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|