first pass at access control setup

This commit is contained in:
2026-03-24 14:45:07 -05:00
parent af5175b96a
commit 2ebb03b868
105 changed files with 6163 additions and 1416 deletions

View File

@@ -0,0 +1,707 @@
import { useState } from "react";
import { useSearchParams, Link } from "react-router-dom";
import {
Shield,
User,
Users,
Plus,
Search,
ShieldCheck,
X,
Snowflake,
Sun,
Package,
Tag,
} from "lucide-react";
import {
useIdentities,
useCreateIdentity,
usePermissionSets,
useFreezeIdentity,
useUnfreezeIdentity,
} from "@/hooks/usePermissions";
// The backend IdentitySummary includes `frozen` and `roles` but the generated client type doesn't declare them
interface IdentityRow {
id: number;
login: string;
display_name?: string | null;
frozen?: boolean;
roles?: string[];
attributes: Record<string, unknown>;
}
// The backend PermissionSetSummary includes `roles` but the generated client type doesn't declare it
interface PermissionSetRow {
id: number;
ref: string;
pack_ref?: string | null;
label?: string | null;
description?: string | null;
grants: unknown;
roles?: Array<{
id: number;
permission_set_id: number;
permission_set_ref?: string | null;
role: string;
created: string;
}>;
}
function CreateIdentityModal({ onClose }: { onClose: () => void }) {
const [login, setLogin] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const createIdentity = useCreateIdentity();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
await createIdentity.mutateAsync({
login,
display_name: displayName || undefined,
password: password || undefined,
});
onClose();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to create identity",
);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Create Identity
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div>
<label
htmlFor="login"
className="block text-sm font-medium text-gray-700 mb-1"
>
Login <span className="text-red-500">*</span>
</label>
<input
id="login"
type="text"
value={login}
onChange={(e) => setLogin(e.target.value)}
required
minLength={3}
maxLength={255}
placeholder="e.g. jane.doe"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="display-name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Display Name
</label>
<input
id="display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Jane Doe"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
maxLength={128}
placeholder="Min 8 characters (optional)"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createIdentity.isPending || !login}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createIdentity.isPending ? "Creating\u2026" : "Create Identity"}
</button>
</div>
</form>
</div>
</div>
);
}
function RoleBadges({ roles }: { roles: string[] }) {
const [showTooltip, setShowTooltip] = useState(false);
const visible = roles.slice(0, 3);
const remaining = roles.length - visible.length;
if (roles.length === 0) {
return <span className="text-xs text-gray-400">None</span>;
}
return (
<div className="flex items-center gap-1 flex-wrap">
{visible.map((role) => (
<span
key={role}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
>
{role}
</span>
))}
{remaining > 0 && (
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<span className="text-xs text-gray-500 cursor-default">
and {remaining} more {remaining === 1 ? "role" : "roles"}
</span>
{showTooltip && (
<div className="absolute bottom-full left-0 mb-2 z-20 bg-gray-900 text-white text-xs rounded-lg shadow-lg p-3 whitespace-nowrap">
<p className="font-medium mb-1">All roles:</p>
<ul className="space-y-0.5">
{roles.map((role) => (
<li key={role}>{role}</li>
))}
</ul>
<div className="absolute top-full left-4 w-2 h-2 bg-gray-900 rotate-45 -mt-1" />
</div>
)}
</div>
)}
</div>
);
}
function IdentitiesTab() {
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const pageSize = 20;
const { data, isLoading, error } = useIdentities({ page, pageSize });
const freezeIdentity = useFreezeIdentity();
const unfreezeIdentity = useUnfreezeIdentity();
const identities: IdentityRow[] = (data?.data as IdentityRow[]) || [];
const total = data?.pagination?.total_items || 0;
const totalPages = total ? Math.ceil(total / pageSize) : 0;
const filteredIdentities = searchTerm
? identities.filter((i) => {
const q = searchTerm.toLowerCase();
return (
i.login.toLowerCase().includes(q) ||
(i.display_name || "").toLowerCase().includes(q) ||
(i.roles || []).some((r) => r.toLowerCase().includes(q))
);
})
: identities;
const handleToggleFreeze = async (identity: IdentityRow) => {
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 unfreezeIdentity.mutateAsync(identity.id);
} else {
await freezeIdentity.mutateAsync(identity.id);
}
} catch (err) {
console.error("Failed to " + action + " identity:", err);
}
};
return (
<>
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-600" />
Identities
</h2>
<p className="mt-1 text-sm text-gray-600">
Manage user and service identities, their roles, and permission
assignments
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Create Identity
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow mb-6 p-4">
<div className="max-w-md">
<label
htmlFor="identity-search"
className="block text-sm font-medium text-gray-700 mb-1"
>
<div className="flex items-center gap-2">
<Search className="w-4 h-4" />
Search Identities
</div>
</label>
<input
id="identity-search"
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(1);
}}
placeholder="Search by login, display name, or role..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{filteredIdentities.length > 0 && (
<div className="mt-3 text-sm text-gray-600">
Showing {filteredIdentities.length} of {total} identities
{searchTerm && " (filtered)"}
</div>
)}
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading identities...</p>
</div>
) : error ? (
<div className="p-12 text-center">
<p className="text-red-600">Failed to load identities</p>
<p className="text-sm text-gray-600 mt-2">
{error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
) : !filteredIdentities || filteredIdentities.length === 0 ? (
<div className="p-12 text-center">
<Users className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-gray-600">No identities found</p>
<p className="text-sm text-gray-500 mt-1">
{searchTerm
? "Try adjusting your search"
: "Create your first identity to get started"}
</p>
{!searchTerm && (
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Create Identity
</button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Login
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Display Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredIdentities.map((identity) => (
<tr key={identity.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={"/access-control/identities/" + identity.id}
className="flex items-center gap-2 group"
>
<User className="w-4 h-4 text-gray-400 group-hover:text-blue-500" />
<span className="text-sm font-medium text-blue-600 group-hover:text-blue-800 group-hover:underline">
{identity.login}
</span>
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">
{identity.display_name || "—"}
</span>
</td>
<td className="px-6 py-4">
<RoleBadges roles={identity.roles || []} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
{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>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">
Active
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => handleToggleFreeze(identity)}
disabled={
freezeIdentity.isPending ||
unfreezeIdentity.isPending
}
className={
identity.frozen
? "inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 disabled:opacity-50 transition-colors"
: "inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 disabled:opacity-50 transition-colors"
}
title={
identity.frozen
? "Unfreeze identity"
: "Freeze identity"
}
>
{identity.frozen ? (
<>
<Sun className="w-3 h-3" />
Unfreeze
</>
) : (
<>
<Snowflake className="w-3 h-3" />
Freeze
</>
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="bg-gray-50 px-6 py-4 flex items-center justify-between border-t border-gray-200">
<p className="text-sm text-gray-700">
Page <span className="font-medium">{page}</span> of{" "}
<span className="font-medium">{totalPages}</span>
</p>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="relative inline-flex items-center px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="relative inline-flex items-center px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
)}
</>
)}
</div>
{showCreateModal && (
<CreateIdentityModal onClose={() => setShowCreateModal(false)} />
)}
</>
);
}
function PermissionSetsTab() {
const [searchTerm, setSearchTerm] = useState("");
const { data: rawData, isLoading, error } = usePermissionSets();
const permissionSets: PermissionSetRow[] =
(rawData as PermissionSetRow[]) || [];
const filteredSets = searchTerm
? permissionSets.filter(
(ps) =>
ps.ref.toLowerCase().includes(searchTerm.toLowerCase()) ||
(ps.label || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(ps.pack_ref || "").toLowerCase().includes(searchTerm.toLowerCase()),
)
: permissionSets;
const grantsCount = (grants: unknown): number => {
if (Array.isArray(grants)) return grants.length;
if (typeof grants === "object" && grants !== null)
return Object.keys(grants).length;
return 0;
};
return (
<>
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-indigo-600" />
Permission Sets
</h2>
<p className="mt-1 text-sm text-gray-600">
Browse permission sets and manage their role assignments
</p>
</div>
<div className="bg-white rounded-lg shadow mb-6 p-4">
<div className="max-w-md">
<label
htmlFor="permset-search"
className="block text-sm font-medium text-gray-700 mb-1"
>
<div className="flex items-center gap-2">
<Search className="w-4 h-4" />
Search Permission Sets
</div>
</label>
<input
id="permset-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by ref, label, or pack..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{filteredSets.length > 0 && (
<div className="mt-3 text-sm text-gray-600">
Showing {filteredSets.length} of {permissionSets.length} permission
sets{searchTerm && " (filtered)"}
</div>
)}
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading permission sets...</p>
</div>
) : error ? (
<div className="p-12 text-center">
<p className="text-red-600">Failed to load permission sets</p>
<p className="text-sm text-gray-600 mt-2">
{error instanceof Error ? error.message : "Unknown error"}
</p>
</div>
) : !filteredSets || filteredSets.length === 0 ? (
<div className="p-12 text-center">
<ShieldCheck className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-4 text-gray-600">No permission sets found</p>
<p className="text-sm text-gray-500 mt-1">
{searchTerm
? "Try adjusting your search"
: "Permission sets are defined in packs"}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reference
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Label
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Pack
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Grants
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredSets.map((ps) => (
<tr key={ps.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={"/access-control/permission-sets/" + ps.ref}
className="flex items-center gap-2 group"
>
<Shield className="w-4 h-4 text-indigo-400 group-hover:text-indigo-600" />
<span className="text-sm font-mono font-medium text-blue-600 group-hover:text-blue-800 group-hover:underline">
{ps.ref}
</span>
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">
{ps.label || "\u2014"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{ps.pack_ref ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<Package className="w-3 h-3" />
{ps.pack_ref}
</span>
) : (
<span className="text-sm text-gray-400">
{"\u2014"}
</span>
)}
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{ps.roles && ps.roles.length > 0 ? (
ps.roles.map((ra) => (
<span
key={ra.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
>
<Tag className="w-3 h-3" />
{ra.role}
</span>
))
) : (
<span className="text-xs text-gray-400">None</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">
{grantsCount(ps.grants)} grant
{grantsCount(ps.grants) !== 1 ? "s" : ""}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
export default function AccessControlPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") || "identities";
const setTab = (tab: string) => {
setSearchParams({ tab });
};
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<Shield className="w-8 h-8 text-indigo-600" />
Access Control
</h1>
<p className="mt-2 text-gray-600">
Manage identities, permission sets, and role-based access control
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setTab("identities")}
className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === "identities"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
Identities
</div>
</button>
<button
onClick={() => setTab("permission-sets")}
className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === "permission-sets"
? "border-indigo-500 text-indigo-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
Permission Sets
</div>
</button>
</nav>
</div>
{activeTab === "identities" ? <IdentitiesTab /> : <PermissionSetsTab />}
</div>
);
}

View File

@@ -0,0 +1,368 @@
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">
&#x2715;
</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>
);
}

View File

@@ -0,0 +1,619 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import {
ArrowLeft,
BarChart3,
Globe,
History,
Key,
MessageSquare,
Package,
Plus,
Shield,
Tag,
Trash2,
Users,
} from "lucide-react";
import {
usePermissionSets,
useCreatePermissionSetRoleAssignment,
useDeletePermissionSetRoleAssignment,
} from "@/hooks/usePermissions";
import { navIcons } from "@/components/layout/navIcons";
// ── Domain interfaces ──────────────────────────────────────────────────────────
interface PermissionSetRoleAssignment {
id: number;
permission_set_id: number;
permission_set_ref: string | null;
role: string;
created: string;
}
interface PermissionSetWithRoles {
id: number;
ref: string;
pack_ref?: string | null;
label?: string | null;
description?: string | null;
grants: unknown;
roles?: PermissionSetRoleAssignment[];
}
// ── Grants model ───────────────────────────────────────────────────────────────
interface GrantConstraints {
pack_refs?: string[];
owner?: string; // "self" | "any" | "none"
owner_types?: string[];
owner_refs?: string[];
visibility?: string[];
execution_scope?: string; // "self" | "descendants" | "any"
refs?: string[];
ids?: number[];
encrypted?: boolean;
attributes?: Record<string, unknown>;
}
interface ParsedGrant {
resource: string;
actions: string[];
constraints?: GrantConstraints;
}
function parseGrants(raw: unknown): ParsedGrant[] {
if (!Array.isArray(raw)) return [];
return raw.filter(
(g): g is ParsedGrant =>
typeof g === "object" &&
g !== null &&
typeof (g as ParsedGrant).resource === "string" &&
Array.isArray((g as ParsedGrant).actions),
);
}
// ── Display metadata ───────────────────────────────────────────────────────────
type ResourceMeta = {
icon: React.ComponentType<{ className?: string }>;
color: string;
label: string;
};
const RESOURCE_META: Record<string, ResourceMeta> = {
packs: { icon: navIcons.packs, color: "text-green-600", label: "Packs" },
actions: {
icon: navIcons.actions,
color: "text-yellow-500",
label: "Actions",
},
rules: { icon: navIcons.rules, color: "text-blue-600", label: "Rules" },
triggers: {
icon: navIcons.triggers,
color: "text-orange-500",
label: "Triggers",
},
executions: {
icon: navIcons.executions,
color: "text-purple-600",
label: "Executions",
},
events: { icon: navIcons.events, color: "text-cyan-600", label: "Events" },
enforcements: {
icon: navIcons.enforcements,
color: "text-red-500",
label: "Enforcements",
},
inquiries: {
icon: MessageSquare,
color: "text-teal-600",
label: "Inquiries",
},
keys: { icon: navIcons.keys, color: "text-amber-600", label: "Keys" },
artifacts: {
icon: navIcons.artifacts,
color: "text-indigo-500",
label: "Artifacts",
},
webhooks: { icon: Globe, color: "text-sky-600", label: "Webhooks" },
analytics: { icon: BarChart3, color: "text-rose-500", label: "Analytics" },
history: { icon: History, color: "text-gray-500", label: "History" },
identities: { icon: Users, color: "text-blue-700", label: "Identities" },
permissions: {
icon: navIcons.accessControl,
color: "text-indigo-600",
label: "Permissions",
},
runtimes: {
icon: navIcons.runtimes,
color: "text-blue-600",
label: "Runtimes",
},
sensors: {
icon: navIcons.sensors,
color: "text-purple-600",
label: "Sensors",
},
};
const ACTION_STYLE: Record<string, string> = {
read: "bg-slate-100 text-slate-700",
create: "bg-emerald-100 text-emerald-800",
update: "bg-amber-100 text-amber-800",
delete: "bg-red-100 text-red-800",
execute: "bg-violet-100 text-violet-800",
cancel: "bg-orange-100 text-orange-800",
respond: "bg-cyan-100 text-cyan-800",
manage: "bg-indigo-100 text-indigo-800",
};
// ── Constraint chips ───────────────────────────────────────────────────────────
function ConstraintChips({ c }: { c: GrantConstraints }) {
const chips: React.ReactNode[] = [];
if (c.pack_refs?.length) {
chips.push(
<span
key="pack_refs"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-green-50 text-green-700 border border-green-200"
>
<Package className="w-3 h-3 shrink-0" />
{c.pack_refs.join(", ")}
</span>,
);
}
if (c.owner) {
const labels: Record<string, string> = {
self: "Own resources",
any: "Any owner",
none: "No owner",
};
chips.push(
<span
key="owner"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-700 border border-blue-200"
>
Owner: {labels[c.owner] ?? c.owner}
</span>,
);
}
if (c.owner_types?.length) {
chips.push(
<span
key="owner_types"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200"
>
Type: {c.owner_types.join(", ")}
</span>,
);
}
if (c.owner_refs?.length) {
chips.push(
<span
key="owner_refs"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200 font-mono"
>
Owner: {c.owner_refs.join(", ")}
</span>,
);
}
if (c.visibility?.length) {
chips.push(
<span
key="visibility"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-sky-50 text-sky-700 border border-sky-200"
>
Visibility: {c.visibility.join(", ")}
</span>,
);
}
if (c.execution_scope) {
const labels: Record<string, string> = {
self: "Own executions",
descendants: "Own + children",
any: "All executions",
};
chips.push(
<span
key="execution_scope"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-purple-50 text-purple-700 border border-purple-200"
>
Scope: {labels[c.execution_scope] ?? c.execution_scope}
</span>,
);
}
if (c.refs?.length) {
chips.push(
<span
key="refs"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200 font-mono"
>
{c.refs.join(", ")}
</span>,
);
}
if (c.ids?.length) {
chips.push(
<span
key="ids"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200"
>
IDs: {c.ids.join(", ")}
</span>,
);
}
if (c.encrypted !== undefined) {
chips.push(
<span
key="encrypted"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-amber-50 text-amber-700 border border-amber-200"
>
<Key className="w-3 h-3 shrink-0" />
{c.encrypted ? "Encrypted only" : "Unencrypted only"}
</span>,
);
}
if (c.attributes && Object.keys(c.attributes).length > 0) {
const text = Object.entries(c.attributes)
.map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
.join(", ");
chips.push(
<span
key="attributes"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-rose-50 text-rose-700 border border-rose-200 font-mono"
>
{text}
</span>,
);
}
if (chips.length === 0) {
return <span className="text-xs text-gray-300"></span>;
}
return <div className="flex flex-col gap-1">{chips}</div>;
}
// ── Grants table ───────────────────────────────────────────────────────────────
function GrantsView({ grants }: { grants: ParsedGrant[] }) {
if (grants.length === 0) {
return (
<div className="p-8 text-center">
<Shield className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500">No grants defined</p>
</div>
);
}
const hasConstraints = grants.some(
(g) => g.constraints && Object.keys(g.constraints).length > 0,
);
return (
<div className="overflow-y-auto max-h-[28rem]">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0 z-10">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-36">
Resource
</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Permissions
</th>
{hasConstraints && (
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Conditions
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{grants.map((grant, i) => {
const meta = RESOURCE_META[grant.resource];
const Icon = meta?.icon ?? Shield;
const iconColor = meta?.color ?? "text-gray-400";
const label =
meta?.label ??
grant.resource.charAt(0).toUpperCase() + grant.resource.slice(1);
return (
<tr key={i} className="hover:bg-gray-50">
<td className="px-4 py-2.5 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<Icon className={`w-3.5 h-3.5 shrink-0 ${iconColor}`} />
<span className="text-sm font-medium text-gray-800">
{label}
</span>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{grant.actions.map((action) => (
<span
key={action}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
ACTION_STYLE[action] ?? "bg-gray-100 text-gray-700"
}`}
>
{action}
</span>
))}
</div>
</td>
{hasConstraints && (
<td className="px-4 py-2.5">
{grant.constraints &&
Object.keys(grant.constraints).length > 0 ? (
<ConstraintChips c={grant.constraints} />
) : (
<span className="text-xs text-gray-300"></span>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────────
export default function PermissionSetDetailPage() {
const { ref } = useParams<{ ref: string }>();
const { data: permissionSetsRaw, isLoading, error } = usePermissionSets();
const createRoleAssignment = useCreatePermissionSetRoleAssignment();
const deleteRoleAssignment = useDeletePermissionSetRoleAssignment();
const [newRole, setNewRole] = useState("");
const [showAddRole, setShowAddRole] = useState(false);
const permissionSets = permissionSetsRaw as
| PermissionSetWithRoles[]
| undefined;
const permissionSet = permissionSets?.find((ps) => ps.ref === ref);
const handleAddRole = async (e: React.FormEvent) => {
e.preventDefault();
if (!newRole.trim()) return;
try {
await createRoleAssignment.mutateAsync({
permissionSetId: permissionSet!.id,
role: newRole.trim(),
});
setNewRole("");
setShowAddRole(false);
} catch (err) {
console.error("Failed to add role:", err);
}
};
const handleDeleteRole = async (assignmentId: number, roleName: string) => {
if (window.confirm(`Remove role "${roleName}" from this permission set?`)) {
try {
await deleteRoleAssignment.mutateAsync(assignmentId);
} catch (err) {
console.error("Failed to delete role assignment:", err);
}
}
};
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 permission set
</p>
</div>
</div>
</div>
);
}
if (error || !permissionSet) {
return (
<div className="p-6">
<Link
to="/access-control?tab=permission-sets"
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">
{error
? "Failed to load permission set"
: "Permission set not found"}
</p>
</div>
</div>
);
}
const roles = permissionSet.roles || [];
const parsedGrants = parseGrants(permissionSet.grants);
return (
<div className="p-6">
{/* Back link */}
<Link
to="/access-control?tab=permission-sets"
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="flex-shrink-0 w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
<Shield className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 font-mono">
{permissionSet.ref}
</h1>
{permissionSet.label && (
<p className="text-lg text-gray-700 mt-0.5">
{permissionSet.label}
</p>
)}
{permissionSet.description && (
<p className="text-sm text-gray-500 mt-1">
{permissionSet.description}
</p>
)}
</div>
</div>
{permissionSet.pack_ref && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
<Package className="w-3.5 h-3.5" />
{permissionSet.pack_ref}
</span>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Roles Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">
Role Assignments
</h2>
<span className="text-sm text-gray-500">({roles.length})</span>
</div>
<button
onClick={() => setShowAddRole(!showAddRole)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md transition-colors"
>
<Plus className="w-4 h-4" />
Add Role
</button>
</div>
{showAddRole && (
<form
onSubmit={handleAddRole}
className="px-6 py-3 bg-blue-50 border-b border-blue-100 flex items-center gap-3"
>
<input
type="text"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
placeholder="Enter role name…"
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
<button
type="submit"
disabled={!newRole.trim() || createRoleAssignment.isPending}
className="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{createRoleAssignment.isPending ? "Adding…" : "Add"}
</button>
<button
type="button"
onClick={() => {
setShowAddRole(false);
setNewRole("");
}}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
Cancel
</button>
</form>
)}
{createRoleAssignment.isError && (
<div className="px-6 py-2 bg-red-50 border-b border-red-100">
<p className="text-sm text-red-600">
Failed to add role.{" "}
{createRoleAssignment.error instanceof Error
? createRoleAssignment.error.message
: "Please try again."}
</p>
</div>
)}
<div className="divide-y divide-gray-100">
{roles.length === 0 ? (
<div className="px-6 py-8 text-center">
<Tag className="mx-auto h-8 w-8 text-gray-300" />
<p className="mt-2 text-sm text-gray-500">
No roles assigned to this permission set
</p>
<p className="text-xs text-gray-400 mt-1">
Identities with a matching role will inherit these grants
</p>
</div>
) : (
roles.map((assignment) => (
<div
key={assignment.id}
className="px-6 py-3 flex items-center justify-between hover:bg-gray-50"
>
<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">
{assignment.role}
</span>
<span className="text-xs text-gray-400">
Added {new Date(assignment.created).toLocaleDateString()}
</span>
</div>
<button
onClick={() =>
handleDeleteRole(assignment.id, assignment.role)
}
className="text-red-400 hover:text-red-600 p-1 rounded transition-colors"
title="Remove role assignment"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
{/* Grants Section */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center gap-2">
<Shield className="w-5 h-5 text-gray-500" />
<h2 className="text-lg font-semibold text-gray-900">Grants</h2>
<span className="text-sm text-gray-500">
({parsedGrants.length})
</span>
</div>
<GrantsView grants={parsedGrants} />
</div>
</div>
</div>
);
}