369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
import { useState } from "react";
|
|
import { useParams, Link } from "react-router-dom";
|
|
import {
|
|
Shield,
|
|
ArrowLeft,
|
|
Trash2,
|
|
Plus,
|
|
Tag,
|
|
Snowflake,
|
|
Sun,
|
|
ShieldCheck,
|
|
User,
|
|
FileJson,
|
|
Search,
|
|
} from "lucide-react";
|
|
import {
|
|
useIdentity,
|
|
usePermissionSets,
|
|
useCreateIdentityRoleAssignment,
|
|
useDeleteIdentityRoleAssignment,
|
|
useCreatePermissionAssignment,
|
|
useDeletePermissionAssignment,
|
|
useFreezeIdentity,
|
|
useUnfreezeIdentity,
|
|
} from "@/hooks/usePermissions";
|
|
|
|
interface RoleAssignment {
|
|
id: number;
|
|
identity_id: number;
|
|
role: string;
|
|
source: string;
|
|
managed: boolean;
|
|
created: string;
|
|
updated: string;
|
|
}
|
|
|
|
interface DirectPermission {
|
|
id: number;
|
|
identity_id: number;
|
|
permission_set_id: number;
|
|
permission_set_ref: string;
|
|
created: string;
|
|
}
|
|
|
|
interface IdentityDetail {
|
|
id: number;
|
|
login: string;
|
|
display_name: string | null;
|
|
frozen: boolean;
|
|
attributes: Record<string, unknown>;
|
|
roles: RoleAssignment[];
|
|
direct_permissions: DirectPermission[];
|
|
}
|
|
|
|
export default function IdentityDetailPage() {
|
|
const { id: idParam } = useParams<{ id: string }>();
|
|
const id = Number(idParam) || 0;
|
|
|
|
const { data: rawData, isLoading, error } = useIdentity(id);
|
|
const { data: permissionSets } = usePermissionSets();
|
|
|
|
const createRoleMutation = useCreateIdentityRoleAssignment();
|
|
const deleteRoleMutation = useDeleteIdentityRoleAssignment();
|
|
const createPermMutation = useCreatePermissionAssignment();
|
|
const deletePermMutation = useDeletePermissionAssignment();
|
|
const freezeMutation = useFreezeIdentity();
|
|
const unfreezeMutation = useUnfreezeIdentity();
|
|
|
|
const [showAddRole, setShowAddRole] = useState(false);
|
|
const [newRole, setNewRole] = useState("");
|
|
const [showAssignPerm, setShowAssignPerm] = useState(false);
|
|
const [selectedPermSetRef, setSelectedPermSetRef] = useState("");
|
|
const [permSetSearch, setPermSetSearch] = useState("");
|
|
|
|
const identity = (rawData as unknown as { data: IdentityDetail } | undefined)?.data;
|
|
|
|
const handleAddRole = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newRole.trim()) return;
|
|
try {
|
|
await createRoleMutation.mutateAsync({ identityId: id, role: newRole.trim() });
|
|
setNewRole("");
|
|
setShowAddRole(false);
|
|
} catch (err) {
|
|
console.error("Failed to add role:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteRole = async (assignmentId: number, role: string) => {
|
|
if (window.confirm("Remove role \"" + role + "\" from this identity?")) {
|
|
try {
|
|
await deleteRoleMutation.mutateAsync(assignmentId);
|
|
} catch (err) {
|
|
console.error("Failed to delete role assignment:", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAssignPermission = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedPermSetRef) return;
|
|
try {
|
|
await createPermMutation.mutateAsync({ identity_id: id, permission_set_ref: selectedPermSetRef });
|
|
setSelectedPermSetRef("");
|
|
setPermSetSearch("");
|
|
setShowAssignPerm(false);
|
|
} catch (err) {
|
|
console.error("Failed to assign permission set:", err);
|
|
}
|
|
};
|
|
|
|
const handleDeletePermission = async (assignmentId: number, ref: string) => {
|
|
if (window.confirm("Remove permission set \"" + ref + "\" from this identity?")) {
|
|
try {
|
|
await deletePermMutation.mutateAsync(assignmentId);
|
|
} catch (err) {
|
|
console.error("Failed to remove permission assignment:", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleToggleFreeze = async () => {
|
|
if (!identity) return;
|
|
const action = identity.frozen ? "unfreeze" : "freeze";
|
|
if (!window.confirm("Are you sure you want to " + action + " identity \"" + identity.login + "\"?")) return;
|
|
try {
|
|
if (identity.frozen) {
|
|
await unfreezeMutation.mutateAsync(id);
|
|
} else {
|
|
await freezeMutation.mutateAsync(id);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to " + action + " identity:", err);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
|
|
|
const assignedPermSetRefs = new Set(identity?.direct_permissions?.map((p) => p.permission_set_ref) ?? []);
|
|
const availablePermSets = (permissionSets ?? []).filter((ps) => !assignedPermSetRefs.has(ps.ref));
|
|
const filteredAvailablePermSets = permSetSearch.trim()
|
|
? availablePermSets.filter((ps) =>
|
|
ps.ref.toLowerCase().includes(permSetSearch.toLowerCase()) ||
|
|
(ps.label ?? "").toLowerCase().includes(permSetSearch.toLowerCase())
|
|
)
|
|
: availablePermSets;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
|
<p className="mt-3 text-sm text-gray-500">Loading identity...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !identity) {
|
|
return (
|
|
<div className="p-6">
|
|
<Link to="/access-control" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6">
|
|
<ArrowLeft className="w-4 h-4" /> Back to Access Control
|
|
</Link>
|
|
<div className="bg-white rounded-lg shadow p-12 text-center">
|
|
<Shield className="mx-auto h-12 w-12 text-gray-400" />
|
|
<p className="mt-4 text-red-600">Failed to load identity</p>
|
|
<p className="text-sm text-gray-500 mt-1">{error instanceof Error ? error.message : "Identity not found"}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-5xl">
|
|
<Link to="/access-control" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 mb-6">
|
|
<ArrowLeft className="w-4 h-4" /> Back to Access Control
|
|
</Link>
|
|
|
|
{/* Header */}
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="bg-blue-100 p-3 rounded-full">
|
|
<User className="w-6 h-6 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold text-gray-900">{identity.login}</h1>
|
|
{identity.frozen && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
|
|
<Snowflake className="w-3 h-3" /> Frozen
|
|
</span>
|
|
)}
|
|
</div>
|
|
{identity.display_name && <p className="text-gray-600 mt-1">{identity.display_name}</p>}
|
|
<p className="text-sm text-gray-400 mt-1">ID: {identity.id}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={handleToggleFreeze} disabled={freezeMutation.isPending || unfreezeMutation.isPending}
|
|
className={"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 " + (identity.frozen ? "bg-green-50 text-green-700 hover:bg-green-100 border border-green-200" : "bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200")}>
|
|
{identity.frozen ? (<><Sun className="w-4 h-4" /> Unfreeze</>) : (<><Snowflake className="w-4 h-4" /> Freeze</>)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Roles Section */}
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Tag className="w-5 h-5 text-violet-500" />
|
|
<h2 className="text-lg font-semibold text-gray-900">Role Assignments</h2>
|
|
<span className="text-sm text-gray-500">({identity.roles?.length || 0})</span>
|
|
</div>
|
|
<button onClick={() => setShowAddRole(!showAddRole)} className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors">
|
|
<Plus className="w-4 h-4" /> Add Role
|
|
</button>
|
|
</div>
|
|
|
|
{showAddRole && (
|
|
<form onSubmit={handleAddRole} className="flex items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg">
|
|
<input type="text" value={newRole} onChange={(e) => setNewRole(e.target.value)} placeholder="Role name (e.g. admin, operator, viewer)" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500 text-sm" autoFocus />
|
|
<button type="submit" disabled={!newRole.trim() || createRoleMutation.isPending} className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 disabled:opacity-50 text-sm transition-colors">
|
|
{createRoleMutation.isPending ? "Adding..." : "Add"}
|
|
</button>
|
|
<button type="button" onClick={() => { setShowAddRole(false); setNewRole(""); }} className="px-4 py-2 text-gray-600 hover:text-gray-900 text-sm">Cancel</button>
|
|
</form>
|
|
)}
|
|
|
|
{identity.roles && identity.roles.length > 0 ? (
|
|
<div className="divide-y divide-gray-100">
|
|
{identity.roles.map((ra) => (
|
|
<div key={ra.id} className="flex items-center justify-between py-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">{ra.role}</span>
|
|
<span className="text-xs text-gray-500">Source: {ra.source}</span>
|
|
{ra.managed && (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">Managed</span>
|
|
)}
|
|
<span className="text-xs text-gray-400">{formatDate(ra.created)}</span>
|
|
</div>
|
|
{!ra.managed && (
|
|
<button onClick={() => handleDeleteRole(ra.id, ra.role)} className="text-red-400 hover:text-red-600 p-1" title="Remove role">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-6">
|
|
<Tag className="mx-auto h-8 w-8 text-gray-300" />
|
|
<p className="mt-2 text-sm text-gray-500">No roles assigned</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Direct Permission Sets Section */}
|
|
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="w-5 h-5 text-indigo-500" />
|
|
<h2 className="text-lg font-semibold text-gray-900">Direct Permission Sets</h2>
|
|
<span className="text-sm text-gray-500">({identity.direct_permissions?.length || 0})</span>
|
|
</div>
|
|
<button onClick={() => setShowAssignPerm(!showAssignPerm)} className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
|
<Plus className="w-4 h-4" /> Assign Permission Set
|
|
</button>
|
|
</div>
|
|
|
|
{showAssignPerm && (
|
|
<form onSubmit={handleAssignPermission} className="mb-4 p-3 bg-gray-50 rounded-lg space-y-2">
|
|
{selectedPermSetRef ? (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-indigo-100 text-indigo-800 rounded-md text-sm font-mono flex-1">
|
|
<Shield className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span className="truncate">{selectedPermSetRef}</span>
|
|
<button type="button" onClick={() => { setSelectedPermSetRef(""); setPermSetSearch(""); }} className="ml-auto text-indigo-500 hover:text-indigo-700">
|
|
✕
|
|
</button>
|
|
</div>
|
|
<button type="submit" disabled={createPermMutation.isPending} className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm transition-colors whitespace-nowrap">
|
|
{createPermMutation.isPending ? "Assigning..." : "Assign"}
|
|
</button>
|
|
<button type="button" onClick={() => { setShowAssignPerm(false); setSelectedPermSetRef(""); setPermSetSearch(""); }} className="px-3 py-2 text-gray-600 hover:text-gray-900 text-sm">Cancel</button>
|
|
</div>
|
|
) : (
|
|
<div className="relative">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
value={permSetSearch}
|
|
onChange={(e) => setPermSetSearch(e.target.value)}
|
|
placeholder="Search permission sets by ref or label..."
|
|
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<button type="button" onClick={() => { setShowAssignPerm(false); setPermSetSearch(""); }} className="px-3 py-2 text-gray-600 hover:text-gray-900 text-sm">Cancel</button>
|
|
</div>
|
|
{permSetSearch.trim() && (
|
|
<div className="mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto">
|
|
{filteredAvailablePermSets.length === 0 ? (
|
|
<div className="px-4 py-3 text-sm text-gray-500">No matching permission sets</div>
|
|
) : (
|
|
filteredAvailablePermSets.map((ps) => (
|
|
<button
|
|
key={ps.ref}
|
|
type="button"
|
|
onClick={() => { setSelectedPermSetRef(ps.ref); setPermSetSearch(""); }}
|
|
className="w-full flex items-start gap-3 px-4 py-2.5 text-left hover:bg-indigo-50 transition-colors"
|
|
>
|
|
<Shield className="w-4 h-4 text-indigo-400 flex-shrink-0 mt-0.5" />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-mono font-medium text-gray-900 truncate">{ps.ref}</div>
|
|
{ps.label && <div className="text-xs text-gray-500 truncate">{ps.label}</div>}
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</form>
|
|
)}
|
|
|
|
{identity.direct_permissions && identity.direct_permissions.length > 0 ? (
|
|
<div className="divide-y divide-gray-100">
|
|
{identity.direct_permissions.map((dp) => (
|
|
<div key={dp.id} className="flex items-center justify-between py-3">
|
|
<div className="flex items-center gap-3">
|
|
<Shield className="w-4 h-4 text-indigo-400" />
|
|
<span className="text-sm font-mono font-medium text-gray-900">{dp.permission_set_ref}</span>
|
|
<span className="text-xs text-gray-400">Assigned {formatDate(dp.created)}</span>
|
|
</div>
|
|
<button onClick={() => handleDeletePermission(dp.id, dp.permission_set_ref)} className="text-red-400 hover:text-red-600 p-1" title="Remove permission set">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-6">
|
|
<ShieldCheck className="mx-auto h-8 w-8 text-gray-300" />
|
|
<p className="mt-2 text-sm text-gray-500">No direct permission sets assigned</p>
|
|
<p className="text-xs text-gray-400 mt-1">Permission sets can also be inherited through roles</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Attributes Section */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<FileJson className="w-5 h-5 text-gray-500" />
|
|
<h2 className="text-lg font-semibold text-gray-900">Attributes</h2>
|
|
</div>
|
|
<pre className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm text-gray-800 overflow-x-auto max-h-64 overflow-y-auto font-mono leading-relaxed">
|
|
{JSON.stringify(identity.attributes, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|