232 lines
6.9 KiB
TypeScript
232 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Card } from "@/components/ui/card";
|
|
|
|
interface Assignment {
|
|
userId: string;
|
|
displayName: string;
|
|
email: string;
|
|
roles: string[];
|
|
assignedAt: string;
|
|
assignedBy: string;
|
|
orphan: boolean;
|
|
}
|
|
|
|
interface OrgMember {
|
|
userId: string;
|
|
email: string;
|
|
displayName: string;
|
|
roles: string[];
|
|
}
|
|
|
|
interface Props {
|
|
tenantName: string;
|
|
/**
|
|
* When false, the panel renders read-only — assignments are visible
|
|
* but the add-user form and remove ✕ buttons are hidden. Pass
|
|
* `canEdit` from the parent server component (= canMutate(user)).
|
|
*/
|
|
canEdit: boolean;
|
|
}
|
|
|
|
/**
|
|
* AssignedUsersPanel — manages the tenant_user_assignments rows for
|
|
* one tenant. Owner sees:
|
|
* - List of currently-assigned users with name, email, role, and
|
|
* an "X" button to revoke.
|
|
* - Dropdown of org members not yet assigned + "Assign" button.
|
|
*
|
|
* `user`-role members see the panel read-only (canEdit=false): they
|
|
* see who else has access to the tenant they're working with, but
|
|
* can't change anything.
|
|
*/
|
|
export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
|
|
const t = useTranslations("assignments");
|
|
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
|
const [members, setMembers] = useState<OrgMember[] | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [pickedUserId, setPickedUserId] = useState("");
|
|
|
|
const refresh = useCallback(async () => {
|
|
setError("");
|
|
try {
|
|
const [aRes, mRes] = await Promise.all([
|
|
fetch(`/api/tenants/${tenantName}/assignments`),
|
|
canEdit
|
|
? fetch(`/api/team`)
|
|
: Promise.resolve(null),
|
|
]);
|
|
if (!aRes.ok) throw new Error("Failed to load assignments");
|
|
const aData = await aRes.json();
|
|
setAssignments(aData.assignments ?? []);
|
|
|
|
if (mRes && mRes.ok) {
|
|
const mData = await mRes.json();
|
|
setMembers(mData.members ?? []);
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
}
|
|
}, [tenantName, canEdit]);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
}, [refresh]);
|
|
|
|
async function handleAssign() {
|
|
if (!pickedUserId || busy) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const res = await fetch(`/api/tenants/${tenantName}/assignments`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ userId: pickedUserId }),
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || "Assign failed");
|
|
}
|
|
setPickedUserId("");
|
|
await refresh();
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleRevoke(userId: string) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const res = await fetch(
|
|
`/api/tenants/${tenantName}/assignments/${encodeURIComponent(userId)}`,
|
|
{ method: "DELETE" }
|
|
);
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || "Revoke failed");
|
|
}
|
|
await refresh();
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
if (assignments === null) {
|
|
return (
|
|
<Card>
|
|
<div className="text-xs text-text-muted">{t("loading")}</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Compute candidates for the assign dropdown: members of the org
|
|
// who hold the `user` role (not owners — they have implicit access)
|
|
// and aren't already assigned.
|
|
const assignedIds = new Set(assignments.map((a) => a.userId));
|
|
const candidates = (members ?? []).filter(
|
|
(m) =>
|
|
!assignedIds.has(m.userId) &&
|
|
m.roles.includes("user") &&
|
|
!m.roles.includes("owner")
|
|
);
|
|
|
|
return (
|
|
<Card>
|
|
{error && (
|
|
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
|
{error}
|
|
<button
|
|
onClick={() => setError("")}
|
|
className="ml-2 text-red-300 hover:text-red-200"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{assignments.length === 0 ? (
|
|
<p className="text-sm text-text-secondary text-center py-3">
|
|
{t("noneAssigned")}
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y divide-border -mx-2">
|
|
{assignments.map((a) => (
|
|
<li
|
|
key={a.userId}
|
|
className="px-2 py-2 flex items-center justify-between gap-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-text-primary truncate">
|
|
{a.orphan ? (
|
|
<span className="text-text-muted italic">
|
|
{a.displayName}
|
|
</span>
|
|
) : (
|
|
a.displayName
|
|
)}
|
|
</div>
|
|
{a.email && (
|
|
<div className="text-xs text-text-muted truncate font-mono">
|
|
{a.email}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{canEdit && (
|
|
<button
|
|
onClick={() => handleRevoke(a.userId)}
|
|
disabled={busy}
|
|
className="text-text-muted/60 hover:text-red-400 transition-colors disabled:opacity-50 text-sm px-2"
|
|
title={t("revoke")}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{canEdit && (
|
|
<div className="mt-4 pt-4 border-t border-border">
|
|
{candidates.length === 0 ? (
|
|
<p className="text-xs text-text-muted text-center py-2">
|
|
{t("noCandidates")}
|
|
</p>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<select
|
|
value={pickedUserId}
|
|
onChange={(e) => setPickedUserId(e.target.value)}
|
|
className="flex-1 px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
|
>
|
|
<option value="">{t("pickUser")}</option>
|
|
{candidates.map((m) => (
|
|
<option key={m.userId} value={m.userId}>
|
|
{m.displayName || m.email}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={handleAssign}
|
|
disabled={busy || !pickedUserId}
|
|
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{busy ? "…" : t("assign")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|