fix: correct page directory casing for case-sensitive filesystems
Rename admin/ → Admin/ and swagger/ → Swagger/ to match router imports. Windows is case-insensitive so the mismatch was invisible locally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
ui/src/pages/Admin/AuditLogPage.tsx
Normal file
59
ui/src/pages/Admin/AuditLogPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuditLog } from '../../api/queries/admin/audit';
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
|
||||
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||
{ key: 'action', header: 'Action' },
|
||||
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
|
||||
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
|
||||
];
|
||||
|
||||
const rows = useMemo(() =>
|
||||
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'All Categories' },
|
||||
{ value: 'AUTH', label: 'Auth' },
|
||||
{ value: 'CONFIG', label: 'Config' },
|
||||
{ value: 'RBAC', label: 'RBAC' },
|
||||
{ value: 'INFRA', label: 'Infra' },
|
||||
]}
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
sortable
|
||||
pageSize={25}
|
||||
expandedContent={(row) => (
|
||||
<div style={{ padding: '0.75rem' }}>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
ui/src/pages/Admin/DatabaseAdminPage.tsx
Normal file
67
ui/src/pages/Admin/DatabaseAdminPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
|
||||
|
||||
export default function DatabaseAdminPage() {
|
||||
const { data: status } = useDatabaseStatus();
|
||||
const { data: pool } = useConnectionPool();
|
||||
const { data: tables } = useDatabaseTables();
|
||||
const { data: queries } = useActiveQueries();
|
||||
const killQuery = useKillQuery();
|
||||
|
||||
const poolPct = pool ? (pool.activeConnections / pool.maximumPoolSize) * 100 : 0;
|
||||
|
||||
const tableColumns: Column<any>[] = [
|
||||
{ key: 'tableName', header: 'Table' },
|
||||
{ key: 'rowCount', header: 'Rows', sortable: true },
|
||||
{ key: 'dataSize', header: 'Data Size' },
|
||||
{ key: 'indexSize', header: 'Index Size' },
|
||||
];
|
||||
|
||||
const queryColumns: Column<any>[] = [
|
||||
{ key: 'pid', header: 'PID' },
|
||||
{ key: 'durationSeconds', header: 'Duration', render: (v) => `${v}s` },
|
||||
{ key: 'state', header: 'State', render: (v) => <Badge label={String(v)} /> },
|
||||
{ key: 'query', header: 'Query', render: (v) => <span style={{ fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>{String(v).slice(0, 80)}</span> },
|
||||
{
|
||||
key: 'pid', header: '', width: '80px',
|
||||
render: (v) => <Button variant="danger" size="sm" onClick={() => killQuery.mutate(v as number)}>Kill</Button>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>Database Administration</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
|
||||
<StatCard label="Version" value={status?.version ?? '—'} />
|
||||
<StatCard label="TimescaleDB" value={status?.timescaleDb ? 'Enabled' : 'Disabled'} />
|
||||
</div>
|
||||
|
||||
{pool && (
|
||||
<Card>
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>Connection Pool</h3>
|
||||
<ProgressBar value={poolPct} />
|
||||
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
||||
<span>Active: {pool.activeConnections}</span>
|
||||
<span>Idle: {pool.idleConnections}</span>
|
||||
<span>Max: {pool.maximumPoolSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.75rem' }}>Tables</h3>
|
||||
<DataTable columns={tableColumns} data={(tables || []).map((t: any) => ({ ...t, id: t.tableName }))} sortable pageSize={20} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.75rem' }}>Active Queries</h3>
|
||||
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
ui/src/pages/Admin/OidcConfigPage.tsx
Normal file
78
ui/src/pages/Admin/OidcConfigPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader } from '@cameleer/design-system';
|
||||
import { adminFetch } from '../../api/queries/admin/admin-api';
|
||||
|
||||
interface OidcConfig {
|
||||
enabled: boolean;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
rolesClaim: string;
|
||||
defaultRoles: string[];
|
||||
autoSignup: boolean;
|
||||
displayNameClaim: string;
|
||||
}
|
||||
|
||||
export default function OidcConfigPage() {
|
||||
const [config, setConfig] = useState<OidcConfig | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch<OidcConfig>('/oidc')
|
||||
.then(setConfig)
|
||||
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'DELETE' });
|
||||
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
|
||||
<Card>
|
||||
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
|
||||
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
|
||||
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
|
||||
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
|
||||
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
|
||||
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
|
||||
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
||||
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||
<Button variant="danger" onClick={handleDelete}>Remove Config</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{success && <Alert variant="success">Configuration saved</Alert>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
ui/src/pages/Admin/OpenSearchAdminPage.tsx
Normal file
58
ui/src/pages/Admin/OpenSearchAdminPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { StatCard, Card, DataTable, Badge, ProgressBar, Spinner } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSearchPerformance, useDeleteIndex } from '../../api/queries/admin/opensearch';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function OpenSearchAdminPage() {
|
||||
const { data: status } = useOpenSearchStatus();
|
||||
const { data: pipeline } = usePipelineStats();
|
||||
const { data: perf } = useOpenSearchPerformance();
|
||||
const { data: indicesData } = useOpenSearchIndices();
|
||||
const deleteIndex = useDeleteIndex();
|
||||
|
||||
const indexColumns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Index' },
|
||||
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
|
||||
{ key: 'docCount', header: 'Documents', sortable: true },
|
||||
{ key: 'size', header: 'Size' },
|
||||
{ key: 'primaryShards', header: 'Shards' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
|
||||
<StatCard label="Health" value={status?.clusterHealth ?? '—'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
|
||||
<StatCard label="Version" value={status?.version ?? '—'} />
|
||||
<StatCard label="Nodes" value={status?.numberOfNodes ?? 0} />
|
||||
</div>
|
||||
|
||||
{pipeline && (
|
||||
<Card>
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>Indexing Pipeline</h3>
|
||||
<ProgressBar value={(pipeline.queueDepth / pipeline.maxQueueSize) * 100} />
|
||||
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}>
|
||||
<span>Queue: {pipeline.queueDepth}/{pipeline.maxQueueSize}</span>
|
||||
<span>Indexed: {pipeline.indexedCount}</span>
|
||||
<span>Failed: {pipeline.failedCount}</span>
|
||||
<span>Rate: {pipeline.indexingRate}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.75rem' }}>Indices</h3>
|
||||
<DataTable
|
||||
columns={indexColumns}
|
||||
data={(indicesData?.indices || []).map((i: any) => ({ ...i, id: i.name }))}
|
||||
sortable
|
||||
pageSize={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
ui/src/pages/Admin/RbacPage.tsx
Normal file
178
ui/src/pages/Admin/RbacPage.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Tabs, DataTable, Badge, Avatar, Button, Input, Modal, FormField,
|
||||
Select, AlertDialog, StatCard, Spinner,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import {
|
||||
useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats,
|
||||
useCreateUser, useUpdateUser, useDeleteUser,
|
||||
useAssignRoleToUser, useRemoveRoleFromUser,
|
||||
useAddUserToGroup, useRemoveUserFromGroup,
|
||||
useCreateGroup, useUpdateGroup, useDeleteGroup,
|
||||
useCreateRole, useUpdateRole, useDeleteRole,
|
||||
useAssignRoleToGroup, useRemoveRoleFromGroup,
|
||||
} from '../../api/queries/admin/rbac';
|
||||
|
||||
export default function RbacPage() {
|
||||
const [tab, setTab] = useState('users');
|
||||
const { data: stats } = useRbacStats();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: '1rem' }}>RBAC Management</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
||||
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
||||
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ label: 'Users', value: 'users', count: stats?.userCount },
|
||||
{ label: 'Groups', value: 'groups', count: stats?.groupCount },
|
||||
{ label: 'Roles', value: 'roles', count: stats?.roleCount },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersTab() {
|
||||
const { data: users, isLoading } = useUsers();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({ username: '', displayName: '', email: '', password: '' });
|
||||
const createUser = useCreateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'userId', header: 'Username', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||
{ key: 'displayName', header: 'Display Name' },
|
||||
{ key: 'email', header: 'Email' },
|
||||
{ key: 'provider', header: 'Provider', render: (v) => <Badge label={String(v)} /> },
|
||||
{
|
||||
key: 'effectiveRoles', header: 'Roles',
|
||||
render: (v) => (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
const rows = (users || []).map((u: any) => ({ ...u, id: u.userId }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create User</Button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={rows} pageSize={20} />
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create User">
|
||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||
<FormField label="Username" required><Input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></FormField>
|
||||
<FormField label="Display Name"><Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} /></FormField>
|
||||
<FormField label="Email"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></FormField>
|
||||
<FormField label="Password"><Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></FormField>
|
||||
<Button variant="primary" onClick={() => { createUser.mutate(form); setCreateOpen(false); setForm({ username: '', displayName: '', email: '', password: '' }); }}>Create</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={() => { if (deleteId) deleteUser.mutate(deleteId); setDeleteId(null); }}
|
||||
title="Delete User"
|
||||
description={`Are you sure you want to delete user "${deleteId}"?`}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsTab() {
|
||||
const { data: groups, isLoading } = useGroups();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '' });
|
||||
const createGroup = useCreateGroup();
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||
{ key: 'members', header: 'Members', render: (v) => String((v as any[])?.length ?? 0) },
|
||||
{
|
||||
key: 'effectiveRoles', header: 'Roles',
|
||||
render: (v) => (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
|
||||
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Group</Button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={groups || []} pageSize={20} />
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Group">
|
||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
||||
<Button variant="primary" onClick={() => { createGroup.mutate(form); setCreateOpen(false); setForm({ name: '' }); }}>Create</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesTab() {
|
||||
const { data: roles, isLoading } = useRoles();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', description: '', scope: '' });
|
||||
const createRole = useCreateRole();
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
|
||||
{ key: 'description', header: 'Description' },
|
||||
{ key: 'scope', header: 'Scope', render: (v) => v ? <Badge label={String(v)} /> : null },
|
||||
{ key: 'system', header: 'System', render: (v) => v ? <Badge label="System" color="warning" /> : null },
|
||||
{ key: 'effectivePrincipals', header: 'Users', render: (v) => String((v as any[])?.length ?? 0) },
|
||||
];
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Role</Button>
|
||||
</div>
|
||||
<DataTable columns={columns} data={roles || []} pageSize={20} />
|
||||
|
||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Role">
|
||||
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
|
||||
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
|
||||
<FormField label="Description"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></FormField>
|
||||
<FormField label="Scope"><Input value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} /></FormField>
|
||||
<Button variant="primary" onClick={() => { createRole.mutate(form); setCreateOpen(false); setForm({ name: '', description: '', scope: '' }); }}>Create</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user