first pass at access control setup
This commit is contained in:
707
web/src/pages/access-control/AccessControlPage.tsx
Normal file
707
web/src/pages/access-control/AccessControlPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
368
web/src/pages/access-control/IdentityDetailPage.tsx
Normal file
368
web/src/pages/access-control/IdentityDetailPage.tsx
Normal 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">
|
||||
✕
|
||||
</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>
|
||||
);
|
||||
}
|
||||
619
web/src/pages/access-control/PermissionSetDetailPage.tsx
Normal file
619
web/src/pages/access-control/PermissionSetDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user