unified UI thing

This commit is contained in:
2025-08-27 11:22:12 -04:00
parent fd2a756db3
commit 0663646e40
36 changed files with 8618 additions and 1 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

1
kms/web/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

1
kms/web/dist/211.js vendored Normal file

File diff suppressed because one or more lines are too long

2
kms/web/dist/265.js vendored Normal file

File diff suppressed because one or more lines are too long

21
kms/web/dist/265.js.LICENSE.txt vendored Normal file
View File

@ -0,0 +1,21 @@
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/

1
kms/web/dist/396.js vendored Normal file

File diff suppressed because one or more lines are too long

2
kms/web/dist/540.js vendored Normal file

File diff suppressed because one or more lines are too long

9
kms/web/dist/540.js.LICENSE.txt vendored Normal file
View File

@ -0,0 +1,9 @@
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

2
kms/web/dist/63.js vendored Normal file

File diff suppressed because one or more lines are too long

9
kms/web/dist/63.js.LICENSE.txt vendored Normal file
View File

@ -0,0 +1,9 @@
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

1
kms/web/dist/665.js vendored Normal file

File diff suppressed because one or more lines are too long

1
kms/web/dist/870.js vendored Normal file

File diff suppressed because one or more lines are too long

2
kms/web/dist/875.js vendored Normal file

File diff suppressed because one or more lines are too long

9
kms/web/dist/875.js.LICENSE.txt vendored Normal file
View File

@ -0,0 +1,9 @@
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

2
kms/web/dist/961.js vendored Normal file

File diff suppressed because one or more lines are too long

19
kms/web/dist/961.js.LICENSE.txt vendored Normal file
View File

@ -0,0 +1,19 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

1
kms/web/dist/index.html vendored Normal file
View File

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="KMS - Key Management System"/><title>KMS</title><script defer="defer" src="main.js"></script><script defer="defer" src="remoteEntry.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

2
kms/web/dist/main.js vendored Normal file

File diff suppressed because one or more lines are too long

21
kms/web/dist/main.js.LICENSE.txt vendored Normal file
View File

@ -0,0 +1,21 @@
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/

1
kms/web/dist/remoteEntry.js vendored Normal file

File diff suppressed because one or more lines are too long

5979
kms/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
kms/web/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "kms",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production",
"dev": "webpack serve --mode development"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0",
"@mantine/core": "^7.0.0",
"@mantine/hooks": "^7.0.0",
"@mantine/notifications": "^7.0.0",
"@mantine/dates": "^7.0.0",
"@mantine/form": "^7.0.0",
"@tabler/icons-react": "^2.40.0",
"axios": "^1.11.0",
"dayjs": "^1.11.13"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"babel-loader": "^9.1.2",
"css-loader": "^6.7.3",
"html-webpack-plugin": "^5.5.0",
"style-loader": "^3.3.1",
"typescript": "^4.9.5",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.7.4"
}
}

14
kms/web/public/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="KMS - Key Management System" />
<title>KMS</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

124
kms/web/src/App.tsx Normal file
View File

@ -0,0 +1,124 @@
import React from 'react';
import { Box, Title, Tabs, Stack, Text } from '@mantine/core';
import {
IconApps,
IconKey,
IconTestPipe,
IconFileText,
IconDashboard
} from '@tabler/icons-react';
import Applications from './components/Applications';
import Tokens from './components/Tokens';
import TokenTester from './components/TokenTester';
import Audit from './components/Audit';
import Dashboard from './components/Dashboard';
const App: React.FC = () => {
// Determine current route based on pathname
const getCurrentRoute = () => {
const path = window.location.pathname;
if (path.includes('/applications')) return 'applications';
if (path.includes('/tokens')) return 'tokens';
if (path.includes('/token-tester')) return 'token-tester';
if (path.includes('/audit')) return 'audit';
return 'dashboard';
};
const [currentRoute, setCurrentRoute] = React.useState(getCurrentRoute());
// Listen for URL changes (for when the shell navigates)
React.useEffect(() => {
const handlePopState = () => {
setCurrentRoute(getCurrentRoute());
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const handleTabChange = (value: string | null) => {
if (value) {
// Use history.pushState to update URL and notify shell router
const basePath = '/app/kms';
const newPath = value === 'dashboard' ? basePath : `${basePath}/${value}`;
// Update the URL and internal state
window.history.pushState(null, '', newPath);
setCurrentRoute(value);
// Dispatch a custom event so shell can respond if needed
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
}
};
const renderContent = () => {
switch (currentRoute) {
case 'applications':
return <Applications />;
case 'tokens':
return <Tokens />;
case 'token-tester':
return <TokenTester />;
case 'audit':
return <Audit />;
default:
return <Dashboard />;
}
};
return (
<Box w="100%">
<Stack gap="lg">
<div>
<Title order={1} size="h2" mb="xs">
Key Management System
</Title>
<Text c="dimmed" size="sm">
Manage API keys, tokens, and access permissions
</Text>
</div>
<Tabs value={currentRoute} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab
value="dashboard"
leftSection={<IconDashboard size={16} />}
>
Dashboard
</Tabs.Tab>
<Tabs.Tab
value="applications"
leftSection={<IconApps size={16} />}
>
Applications
</Tabs.Tab>
<Tabs.Tab
value="tokens"
leftSection={<IconKey size={16} />}
>
Tokens
</Tabs.Tab>
<Tabs.Tab
value="token-tester"
leftSection={<IconTestPipe size={16} />}
>
Token Tester
</Tabs.Tab>
<Tabs.Tab
value="audit"
leftSection={<IconFileText size={16} />}
>
Audit Log
</Tabs.Tab>
</Tabs.List>
<Box pt="md">
{renderContent()}
</Box>
</Tabs>
</Stack>
</Box>
);
};
export default App;

View File

@ -0,0 +1,449 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Stack,
Title,
Modal,
TextInput,
MultiSelect,
Group,
ActionIcon,
Badge,
Card,
Text,
Loader,
Alert,
Textarea,
Select,
NumberInput,
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconEye,
IconCopy,
IconAlertCircle,
} from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
import dayjs from 'dayjs';
const Applications: React.FC = () => {
const [applications, setApplications] = useState<Application[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingApp, setEditingApp] = useState<Application | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedApp, setSelectedApp] = useState<Application | null>(null);
const form = useForm<CreateApplicationRequest>({
initialValues: {
app_id: '',
app_link: '',
type: [],
callback_url: '',
token_prefix: '',
token_renewal_duration: '24h',
max_token_duration: '168h',
owner: {
type: 'user',
name: 'Admin User',
owner: 'admin@example.com',
},
},
validate: {
app_id: (value) => value.length < 1 ? 'App ID is required' : null,
app_link: (value) => value.length < 1 ? 'App Link is required' : null,
callback_url: (value) => value.length < 1 ? 'Callback URL is required' : null,
},
});
useEffect(() => {
loadApplications();
}, []);
const loadApplications = async () => {
try {
setLoading(true);
const response = await apiService.getApplications(100, 0);
setApplications(response.data);
} catch (error) {
console.error('Failed to load applications:', error);
notifications.show({
title: 'Error',
message: 'Failed to load applications',
color: 'red',
});
} finally {
setLoading(false);
}
};
const handleSubmit = async (values: CreateApplicationRequest) => {
try {
if (editingApp) {
await apiService.updateApplication(editingApp.app_id, values);
notifications.show({
title: 'Success',
message: 'Application updated successfully',
color: 'green',
});
} else {
await apiService.createApplication(values);
notifications.show({
title: 'Success',
message: 'Application created successfully',
color: 'green',
});
}
setModalOpen(false);
setEditingApp(null);
form.reset();
loadApplications();
} catch (error) {
console.error('Failed to save application:', error);
notifications.show({
title: 'Error',
message: 'Failed to save application',
color: 'red',
});
}
};
const handleEdit = (app: Application) => {
setEditingApp(app);
form.setValues({
app_id: app.app_id,
app_link: app.app_link,
type: app.type,
callback_url: app.callback_url,
token_prefix: app.token_prefix || '',
token_renewal_duration: `${app.token_renewal_duration / 3600}h`,
max_token_duration: `${app.max_token_duration / 3600}h`,
owner: app.owner,
});
setModalOpen(true);
};
const handleDelete = async (appId: string) => {
if (window.confirm('Are you sure you want to delete this application?')) {
try {
await apiService.deleteApplication(appId);
notifications.show({
title: 'Success',
message: 'Application deleted successfully',
color: 'green',
});
loadApplications();
} catch (error) {
console.error('Failed to delete application:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete application',
color: 'red',
});
}
}
};
const handleViewDetails = (app: Application) => {
setSelectedApp(app);
setDetailModalOpen(true);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
notifications.show({
title: 'Copied',
message: 'Copied to clipboard',
color: 'blue',
});
};
const appTypeOptions = [
{ value: 'web', label: 'Web Application' },
{ value: 'mobile', label: 'Mobile Application' },
{ value: 'api', label: 'API Service' },
{ value: 'cli', label: 'CLI Tool' },
{ value: 'service', label: 'Background Service' },
];
const rows = applications.map((app) => (
<Table.Tr key={app.app_id}>
<Table.Td>
<Text fw={500}>{app.app_id}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
{app.type.map((type) => (
<Badge key={type} variant="light" size="sm">
{type}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{app.owner.name} ({app.owner.owner})
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{dayjs(app.created_at).format('MMM DD, YYYY')}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => handleViewDetails(app)}
title="View Details"
>
<IconEye size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(app)}
title="Edit"
>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDelete(app.app_id)}
title="Delete"
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="lg">
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
Applications
</Title>
<Text c="dimmed">
Manage your registered applications and their configurations
</Text>
</div>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => {
setEditingApp(null);
form.reset();
setModalOpen(true);
}}
>
New Application
</Button>
</Group>
{loading ? (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
<Text>Loading applications...</Text>
</Stack>
) : applications.length === 0 ? (
<Card shadow="sm" radius="md" withBorder p="xl">
<Stack align="center" gap="md">
<IconAlertCircle size={48} color="gray" />
<div style={{ textAlign: 'center' }}>
<Text fw={500} mb="xs">
No applications found
</Text>
<Text size="sm" c="dimmed">
Create your first application to get started with the key management system
</Text>
</div>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => {
setEditingApp(null);
form.reset();
setModalOpen(true);
}}
>
Create Application
</Button>
</Stack>
</Card>
) : (
<Card shadow="sm" radius="md" withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Application ID</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Card>
)}
{/* Create/Edit Modal */}
<Modal
opened={modalOpen}
onClose={() => {
setModalOpen(false);
setEditingApp(null);
form.reset();
}}
title={editingApp ? 'Edit Application' : 'Create New Application'}
size="lg"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label="Application ID"
placeholder="my-app-id"
required
{...form.getInputProps('app_id')}
disabled={!!editingApp}
/>
<TextInput
label="Application Link"
placeholder="https://myapp.example.com"
required
{...form.getInputProps('app_link')}
/>
<MultiSelect
label="Application Type"
placeholder="Select application types"
data={appTypeOptions}
required
{...form.getInputProps('type')}
/>
<TextInput
label="Callback URL"
placeholder="https://myapp.example.com/callback"
required
{...form.getInputProps('callback_url')}
/>
<TextInput
label="Token Prefix (Optional)"
placeholder="myapp_"
{...form.getInputProps('token_prefix')}
/>
<Group grow>
<TextInput
label="Token Renewal Duration"
placeholder="24h"
{...form.getInputProps('token_renewal_duration')}
/>
<TextInput
label="Max Token Duration"
placeholder="168h"
{...form.getInputProps('max_token_duration')}
/>
</Group>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
onClick={() => {
setModalOpen(false);
setEditingApp(null);
form.reset();
}}
>
Cancel
</Button>
<Button type="submit">
{editingApp ? 'Update Application' : 'Create Application'}
</Button>
</Group>
</Stack>
</form>
</Modal>
{/* Detail Modal */}
<Modal
opened={detailModalOpen}
onClose={() => setDetailModalOpen(false)}
title="Application Details"
size="md"
>
{selectedApp && (
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>Application ID:</Text>
<Group gap="xs">
<Text>{selectedApp.app_id}</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => copyToClipboard(selectedApp.app_id)}
>
<IconCopy size={12} />
</ActionIcon>
</Group>
</Group>
<Group justify="space-between">
<Text fw={500}>HMAC Key:</Text>
<Group gap="xs">
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{selectedApp.hmac_key.substring(0, 16)}...
</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => copyToClipboard(selectedApp.hmac_key)}
>
<IconCopy size={12} />
</ActionIcon>
</Group>
</Group>
<Group justify="space-between">
<Text fw={500}>Application Link:</Text>
<Text size="sm">{selectedApp.app_link}</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Callback URL:</Text>
<Text size="sm">{selectedApp.callback_url}</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Token Renewal:</Text>
<Text size="sm">{selectedApp.token_renewal_duration / 3600}h</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Max Duration:</Text>
<Text size="sm">{selectedApp.max_token_duration / 3600}h</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Created:</Text>
<Text size="sm">{dayjs(selectedApp.created_at).format('MMM DD, YYYY HH:mm')}</Text>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
};
export default Applications;

View File

@ -0,0 +1,460 @@
import React, { useState, useEffect } from 'react';
import {
Stack,
Title,
Card,
Table,
Text,
Badge,
Group,
TextInput,
MultiSelect,
Button,
Pagination,
Loader,
Modal,
Alert,
Grid,
Select,
Code,
Divider,
} from '@mantine/core';
import {
IconSearch,
IconFilter,
IconEye,
IconActivity,
IconAlertCircle,
IconRefresh,
} from '@tabler/icons-react';
import { DatePickerInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import {
apiService,
AuditEvent,
AuditQueryParams,
AuditResponse,
} from '../services/apiService';
import dayjs from 'dayjs';
const Audit: React.FC = () => {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(false);
const [totalEvents, setTotalEvents] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
// Filter states
const [filters, setFilters] = useState<AuditQueryParams>({
limit: pageSize,
offset: 0,
order_by: 'timestamp',
order_desc: true,
});
const eventTypeOptions = [
{ value: 'auth.login', label: 'Authentication - Login' },
{ value: 'auth.logout', label: 'Authentication - Logout' },
{ value: 'auth.token_verified', label: 'Authentication - Token Verified' },
{ value: 'app.created', label: 'Application - Created' },
{ value: 'app.updated', label: 'Application - Updated' },
{ value: 'app.deleted', label: 'Application - Deleted' },
{ value: 'token.created', label: 'Token - Created' },
{ value: 'token.revoked', label: 'Token - Revoked' },
{ value: 'token.validated', label: 'Token - Validated' },
{ value: 'permission.granted', label: 'Permission - Granted' },
{ value: 'permission.denied', label: 'Permission - Denied' },
];
const statusOptions = [
{ value: 'success', label: 'Success' },
{ value: 'failure', label: 'Failure' },
{ value: 'error', label: 'Error' },
{ value: 'warning', label: 'Warning' },
];
useEffect(() => {
loadAuditEvents();
}, [filters]);
const loadAuditEvents = async () => {
try {
setLoading(true);
const response = await apiService.getAuditEvents(filters);
setEvents(response.events);
setTotalEvents(response.total);
} catch (error) {
console.error('Failed to load audit events:', error);
notifications.show({
title: 'Error',
message: 'Failed to load audit events',
color: 'red',
});
} finally {
setLoading(false);
}
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
setFilters(prev => ({
...prev,
offset: (page - 1) * pageSize,
}));
};
const handleFilterChange = (key: keyof AuditQueryParams, value: any) => {
setCurrentPage(1);
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const clearFilters = () => {
setCurrentPage(1);
setFilters({
limit: pageSize,
offset: 0,
order_by: 'timestamp',
order_desc: true,
});
};
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'success':
return 'green';
case 'failure':
case 'error':
return 'red';
case 'warning':
return 'yellow';
default:
return 'gray';
}
};
const getEventTypeColor = (type: string) => {
if (type.startsWith('auth.')) return 'blue';
if (type.startsWith('app.')) return 'purple';
if (type.startsWith('token.')) return 'green';
if (type.startsWith('permission.')) return 'orange';
return 'gray';
};
const handleViewDetails = (event: AuditEvent) => {
setSelectedEvent(event);
setDetailModalOpen(true);
};
const totalPages = Math.ceil(totalEvents / pageSize);
const rows = events.map((event) => (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">
{dayjs(event.timestamp).format('MMM DD, HH:mm:ss')}
</Text>
</Table.Td>
<Table.Td>
<Badge color={getEventTypeColor(event.type)} variant="light" size="sm">
{event.type}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={getStatusColor(event.status)} variant="light" size="sm">
{event.status}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{event.actor_id || 'System'}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={2}>
{event.description}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<Button
variant="subtle"
size="xs"
leftSection={<IconEye size={14} />}
onClick={() => handleViewDetails(event)}
>
Details
</Button>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="lg">
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
Audit Log
</Title>
<Text c="dimmed">
View and search system audit events and security logs
</Text>
</div>
<Button
leftSection={<IconRefresh size={16} />}
variant="light"
onClick={loadAuditEvents}
loading={loading}
>
Refresh
</Button>
</Group>
{/* Filters */}
<Card shadow="sm" radius="md" withBorder p="md">
<Stack gap="md">
<Group justify="space-between">
<Text fw={500} size="sm">Filters</Text>
<Button variant="subtle" size="xs" onClick={clearFilters}>
Clear All
</Button>
</Group>
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MultiSelect
label="Event Types"
placeholder="All types"
data={eventTypeOptions}
value={filters.event_types || []}
onChange={(value) => handleFilterChange('event_types', value.length ? value : undefined)}
clearable
searchable
size="sm"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MultiSelect
label="Status"
placeholder="All statuses"
data={statusOptions}
value={filters.statuses || []}
onChange={(value) => handleFilterChange('statuses', value.length ? value : undefined)}
clearable
size="sm"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<TextInput
label="Actor ID"
placeholder="user@example.com"
value={filters.actor_id || ''}
onChange={(event) => handleFilterChange('actor_id', event.target.value || undefined)}
size="sm"
leftSection={<IconSearch size={16} />}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<TextInput
label="Resource ID"
placeholder="Resource identifier"
value={filters.resource_id || ''}
onChange={(event) => handleFilterChange('resource_id', event.target.value || undefined)}
size="sm"
leftSection={<IconSearch size={16} />}
/>
</Grid.Col>
</Grid>
</Stack>
</Card>
{/* Events Table */}
<Card shadow="sm" radius="md" withBorder>
{loading ? (
<Stack align="center" justify="center" h={300}>
<Loader size="lg" />
<Text>Loading audit events...</Text>
</Stack>
) : events.length === 0 ? (
<Stack align="center" justify="center" h={300}>
<IconActivity size={48} color="gray" />
<div style={{ textAlign: 'center' }}>
<Text fw={500} mb="xs">
No audit events found
</Text>
<Text size="sm" c="dimmed">
{Object.keys(filters).filter(k => k !== 'limit' && k !== 'offset' && k !== 'order_by' && k !== 'order_desc').some(k => filters[k as keyof AuditQueryParams])
? 'Try adjusting your filters or clearing them to see more results'
: 'Audit events will appear here as system activities occur'}
</Text>
</div>
</Stack>
) : (
<>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Timestamp</Table.Th>
<Table.Th>Event Type</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actor</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
{totalPages > 1 && (
<Group justify="space-between" mt="md" px="md" pb="md">
<Text size="sm" c="dimmed">
Showing {(currentPage - 1) * pageSize + 1} to {Math.min(currentPage * pageSize, totalEvents)} of {totalEvents} events
</Text>
<Pagination
total={totalPages}
value={currentPage}
onChange={handlePageChange}
size="sm"
/>
</Group>
)}
</>
)}
</Card>
{/* Event Detail Modal */}
<Modal
opened={detailModalOpen}
onClose={() => setDetailModalOpen(false)}
title="Audit Event Details"
size="lg"
>
{selectedEvent && (
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>Event ID:</Text>
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{selectedEvent.id}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Timestamp:</Text>
<Text size="sm">
{dayjs(selectedEvent.timestamp).format('MMMM DD, YYYY HH:mm:ss')}
</Text>
</Group>
<Group justify="space-between">
<Text fw={500}>Event Type:</Text>
<Badge color={getEventTypeColor(selectedEvent.type)} variant="light">
{selectedEvent.type}
</Badge>
</Group>
<Group justify="space-between">
<Text fw={500}>Status:</Text>
<Badge color={getStatusColor(selectedEvent.status)} variant="light">
{selectedEvent.status}
</Badge>
</Group>
<Group justify="space-between">
<Text fw={500}>Action:</Text>
<Text size="sm">{selectedEvent.action}</Text>
</Group>
<Divider />
<div>
<Text fw={500} mb="xs">Description:</Text>
<Text size="sm">{selectedEvent.description}</Text>
</div>
{selectedEvent.actor_id && (
<Group justify="space-between">
<Text fw={500}>Actor ID:</Text>
<Text size="sm">{selectedEvent.actor_id}</Text>
</Group>
)}
{selectedEvent.actor_ip && (
<Group justify="space-between">
<Text fw={500}>IP Address:</Text>
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{selectedEvent.actor_ip}
</Text>
</Group>
)}
{selectedEvent.resource_id && (
<Group justify="space-between">
<Text fw={500}>Resource ID:</Text>
<Text size="sm">{selectedEvent.resource_id}</Text>
</Group>
)}
{selectedEvent.resource_type && (
<Group justify="space-between">
<Text fw={500}>Resource Type:</Text>
<Badge variant="outline" size="sm">
{selectedEvent.resource_type}
</Badge>
</Group>
)}
{selectedEvent.request_id && (
<Group justify="space-between">
<Text fw={500}>Request ID:</Text>
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{selectedEvent.request_id}
</Text>
</Group>
)}
{selectedEvent.session_id && (
<Group justify="space-between">
<Text fw={500}>Session ID:</Text>
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{selectedEvent.session_id}
</Text>
</Group>
)}
{selectedEvent.user_agent && (
<div>
<Text fw={500} mb="xs">User Agent:</Text>
<Text size="xs" c="dimmed" style={{ fontFamily: 'monospace' }}>
{selectedEvent.user_agent}
</Text>
</div>
)}
{selectedEvent.details && Object.keys(selectedEvent.details).length > 0 && (
<>
<Divider />
<div>
<Text fw={500} mb="xs">Additional Details:</Text>
<Code block>
{JSON.stringify(selectedEvent.details, null, 2)}
</Code>
</div>
</>
)}
</Stack>
)}
</Modal>
</Stack>
);
};
export default Audit;

View File

@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import {
Grid,
Card,
Text,
Group,
Stack,
Badge,
RingProgress,
SimpleGrid,
Title,
ThemeIcon,
Loader,
} from '@mantine/core';
import {
IconApps,
IconKey,
IconUsers,
IconActivity,
IconTrendingUp,
IconAlertTriangle,
} from '@tabler/icons-react';
import { apiService } from '../services/apiService';
interface DashboardStats {
totalApplications: number;
totalTokens: number;
recentActivity: number;
systemHealth: 'healthy' | 'warning' | 'error';
}
const Dashboard: React.FC = () => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
// Load applications and tokens data
const [appsResponse] = await Promise.all([
apiService.getApplications(100, 0),
]);
// Calculate stats
const dashboardStats: DashboardStats = {
totalApplications: appsResponse.count,
totalTokens: 0, // We'd need to aggregate from all apps
recentActivity: 0,
systemHealth: 'healthy',
};
setStats(dashboardStats);
} catch (error) {
console.error('Failed to load dashboard data:', error);
setStats({
totalApplications: 0,
totalTokens: 0,
recentActivity: 0,
systemHealth: 'error',
});
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Stack align="center" justify="center" h={400}>
<Loader size="lg" />
<Text>Loading dashboard...</Text>
</Stack>
);
}
const statCards = [
{
title: 'Applications',
value: stats?.totalApplications || 0,
icon: IconApps,
color: 'blue',
description: 'Active applications',
},
{
title: 'API Tokens',
value: stats?.totalTokens || 0,
icon: IconKey,
color: 'green',
description: 'Generated tokens',
},
{
title: 'Recent Activity',
value: stats?.recentActivity || 0,
icon: IconActivity,
color: 'orange',
description: 'Events today',
},
{
title: 'System Health',
value: stats?.systemHealth === 'healthy' ? '100%' : '85%',
icon: stats?.systemHealth === 'healthy' ? IconTrendingUp : IconAlertTriangle,
color: stats?.systemHealth === 'healthy' ? 'green' : 'yellow',
description: 'System status',
},
];
return (
<Stack gap="lg">
<div>
<Title order={2} mb="xs">
Dashboard Overview
</Title>
<Text c="dimmed">
Monitor your key management system status and metrics
</Text>
</div>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
{statCards.map((stat) => (
<Card key={stat.title} shadow="sm" radius="md" withBorder p="lg">
<Group justify="space-between" mb="xs">
<Text size="sm" c="dimmed" fw={500}>
{stat.title}
</Text>
<ThemeIcon color={stat.color} variant="light" size="lg">
<stat.icon size={20} />
</ThemeIcon>
</Group>
<Text size="xl" fw={700} mb="xs">
{stat.value}
</Text>
<Text size="xs" c="dimmed">
{stat.description}
</Text>
</Card>
))}
</SimpleGrid>
<Grid>
<Grid.Col span={{ base: 12, md: 8 }}>
<Card shadow="sm" radius="md" withBorder p="lg" h={300}>
<Title order={3} mb="md">
Activity Timeline
</Title>
<Stack justify="center" align="center" h={200}>
<Text c="dimmed" ta="center">
Activity timeline will be displayed here
<br />
<Text size="xs" c="dimmed">
Integration with audit events coming soon
</Text>
</Text>
</Stack>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Card shadow="sm" radius="md" withBorder p="lg" h={300}>
<Title order={3} mb="md">
System Status
</Title>
<Stack gap="md" align="center">
<RingProgress
size={120}
thickness={12}
sections={[
{
value: stats?.systemHealth === 'healthy' ? 100 : 85,
color: stats?.systemHealth === 'healthy' ? 'green' : 'yellow',
},
]}
label={
<Text ta="center" fw={700} size="lg">
{stats?.systemHealth === 'healthy' ? '100%' : '85%'}
</Text>
}
/>
<Badge
color={stats?.systemHealth === 'healthy' ? 'green' : 'yellow'}
variant="light"
size="lg"
>
{stats?.systemHealth === 'healthy' ? 'Healthy' : 'Warning'}
</Badge>
<Text size="sm" c="dimmed" ta="center">
All systems operational
</Text>
</Stack>
</Card>
</Grid.Col>
</Grid>
</Stack>
);
};
export default Dashboard;

View File

@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
import {
Stack,
Title,
Card,
TextInput,
Textarea,
Button,
Group,
Text,
Alert,
Badge,
Divider,
Select,
MultiSelect,
Code,
Grid,
Loader,
JsonInput,
} from '@mantine/core';
import {
IconTestPipe,
IconCheck,
IconX,
IconAlertCircle,
IconClock,
IconUser,
IconKey,
} from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import {
apiService,
Application,
VerifyRequest,
VerifyResponse,
} from '../services/apiService';
import dayjs from 'dayjs';
const TokenTester: React.FC = () => {
const [applications, setApplications] = useState<Application[]>([]);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<VerifyResponse | null>(null);
const form = useForm<VerifyRequest>({
initialValues: {
app_id: '',
user_id: '',
token: '',
permissions: [],
},
validate: {
app_id: (value) => value.length < 1 ? 'Application is required' : null,
token: (value) => value.length < 1 ? 'Token is required' : null,
},
});
const availablePermissions = [
'app.read',
'app.write',
'app.delete',
'token.read',
'token.create',
'token.revoke',
'repo.read',
'repo.write',
'repo.admin',
'permission.read',
'permission.write',
];
useEffect(() => {
loadApplications();
}, []);
const loadApplications = async () => {
try {
const response = await apiService.getApplications(100, 0);
setApplications(response.data);
} catch (error) {
console.error('Failed to load applications:', error);
}
};
const handleSubmit = async (values: VerifyRequest) => {
try {
setTesting(true);
setResult(null);
// Clean up the request - remove empty fields
const cleanedValues = {
...values,
user_id: values.user_id || undefined,
permissions: values.permissions && values.permissions.length > 0 ? values.permissions : undefined,
};
const response = await apiService.verifyToken(cleanedValues);
setResult(response);
if (response.valid) {
notifications.show({
title: 'Token Verified',
message: `Token is ${response.permitted ? 'valid and permitted' : 'valid but not permitted'}`,
color: response.permitted ? 'green' : 'orange',
});
} else {
notifications.show({
title: 'Token Invalid',
message: response.error || 'Token verification failed',
color: 'red',
});
}
} catch (error) {
console.error('Failed to verify token:', error);
notifications.show({
title: 'Error',
message: 'Failed to verify token',
color: 'red',
});
} finally {
setTesting(false);
}
};
const getStatusColor = (result: VerifyResponse) => {
if (!result.valid) return 'red';
if (result.valid && result.permitted) return 'green';
return 'orange';
};
const getStatusIcon = (result: VerifyResponse) => {
if (!result.valid) return IconX;
if (result.valid && result.permitted) return IconCheck;
return IconAlertCircle;
};
return (
<Stack gap="lg">
<div>
<Title order={2} mb="xs">
Token Tester
</Title>
<Text c="dimmed">
Test and verify API tokens against your applications
</Text>
</div>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Card shadow="sm" radius="md" withBorder p="lg">
<Title order={3} mb="md">
Test Configuration
</Title>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label="Application"
placeholder="Select an application to test against"
required
data={applications.map(app => ({
value: app.app_id,
label: `${app.app_id} (${app.type.join(', ')})`,
}))}
{...form.getInputProps('app_id')}
/>
<TextInput
label="User ID (Optional)"
placeholder="user@example.com"
description="Leave empty for token-only verification"
{...form.getInputProps('user_id')}
/>
<Textarea
label="Token"
placeholder="Paste your token here..."
required
minRows={3}
{...form.getInputProps('token')}
/>
<MultiSelect
label="Required Permissions (Optional)"
placeholder="Select permissions to test"
description="Leave empty to skip permission checks"
data={availablePermissions.map(perm => ({
value: perm,
label: perm,
}))}
{...form.getInputProps('permissions')}
searchable
/>
<Group justify="flex-end">
<Button
type="submit"
loading={testing}
leftSection={!testing ? <IconTestPipe size={16} /> : <Loader size={16} />}
disabled={applications.length === 0}
>
{testing ? 'Testing...' : 'Test Token'}
</Button>
</Group>
</Stack>
</form>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Card shadow="sm" radius="md" withBorder p="lg" h="100%">
<Title order={3} mb="md">
Test Results
</Title>
{!result && !testing && (
<Stack align="center" justify="center" h={300}>
<IconTestPipe size={48} color="gray" />
<Text c="dimmed" ta="center">
Configure your test parameters and click "Test Token" to see results
</Text>
</Stack>
)}
{testing && (
<Stack align="center" justify="center" h={300}>
<Loader size="lg" />
<Text>Verifying token...</Text>
</Stack>
)}
{result && (
<Stack gap="md">
<Alert
icon={React.createElement(getStatusIcon(result), { size: 16 })}
title={
!result.valid
? 'Token Invalid'
: result.permitted
? 'Token Valid & Permitted'
: 'Token Valid but Not Permitted'
}
color={getStatusColor(result)}
>
{result.error || (
result.valid && result.permitted
? 'Token is valid and has the required permissions'
: result.valid
? 'Token is valid but lacks some required permissions'
: 'Token verification failed'
)}
</Alert>
<Divider />
<Stack gap="xs">
<Group justify="space-between">
<Text fw={500}>Valid:</Text>
<Badge color={result.valid ? 'green' : 'red'} variant="light">
{result.valid ? 'Yes' : 'No'}
</Badge>
</Group>
<Group justify="space-between">
<Text fw={500}>Permitted:</Text>
<Badge color={result.permitted ? 'green' : 'red'} variant="light">
{result.permitted ? 'Yes' : 'No'}
</Badge>
</Group>
<Group justify="space-between">
<Text fw={500}>Token Type:</Text>
<Badge variant="light">
{result.token_type}
</Badge>
</Group>
{result.user_id && (
<Group justify="space-between">
<Text fw={500}>User ID:</Text>
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{result.user_id}
</Text>
</Group>
)}
{result.expires_at && (
<Group justify="space-between">
<Text fw={500}>Expires At:</Text>
<Text size="sm">
{dayjs(result.expires_at).format('MMM DD, YYYY HH:mm')}
</Text>
</Group>
)}
{result.max_valid_at && (
<Group justify="space-between">
<Text fw={500}>Max Valid Until:</Text>
<Text size="sm">
{dayjs(result.max_valid_at).format('MMM DD, YYYY HH:mm')}
</Text>
</Group>
)}
</Stack>
{result.permissions && result.permissions.length > 0 && (
<>
<Divider />
<div>
<Text fw={500} mb="xs">Token Permissions:</Text>
<Group gap="xs">
{result.permissions.map((perm) => (
<Badge key={perm} variant="light" size="sm" color="blue">
{perm}
</Badge>
))}
</Group>
</div>
</>
)}
{result.permission_results && Object.keys(result.permission_results).length > 0 && (
<>
<Divider />
<div>
<Text fw={500} mb="xs">Permission Check Results:</Text>
<Stack gap="xs">
{Object.entries(result.permission_results).map(([permission, granted]) => (
<Group key={permission} justify="space-between">
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{permission}
</Text>
<Badge color={granted ? 'green' : 'red'} variant="light" size="sm">
{granted ? 'Granted' : 'Denied'}
</Badge>
</Group>
))}
</Stack>
</div>
</>
)}
{result.claims && Object.keys(result.claims).length > 0 && (
<>
<Divider />
<div>
<Text fw={500} mb="xs">Token Claims:</Text>
<Code block>
{JSON.stringify(result.claims, null, 2)}
</Code>
</div>
</>
)}
</Stack>
)}
</Card>
</Grid.Col>
</Grid>
{applications.length === 0 && (
<Alert
icon={<IconAlertCircle size={16} />}
title="No Applications Found"
color="yellow"
>
You need to create at least one application before you can test tokens.
</Alert>
)}
</Stack>
);
};
export default TokenTester;

View File

@ -0,0 +1,479 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Stack,
Title,
Modal,
TextInput,
MultiSelect,
Group,
ActionIcon,
Badge,
Card,
Text,
Loader,
Alert,
Select,
Textarea,
Code,
Divider,
} from '@mantine/core';
import {
IconPlus,
IconTrash,
IconEye,
IconCopy,
IconAlertCircle,
IconKey,
IconCheck,
} from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import {
apiService,
Application,
StaticToken,
CreateTokenRequest,
CreateTokenResponse,
} from '../services/apiService';
import dayjs from 'dayjs';
interface TokenWithApp extends StaticToken {
app?: Application;
}
const Tokens: React.FC = () => {
const [applications, setApplications] = useState<Application[]>([]);
const [tokens, setTokens] = useState<TokenWithApp[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [tokenModalOpen, setTokenModalOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null);
const form = useForm<CreateTokenRequest & { app_id: string }>({
initialValues: {
app_id: '',
owner: {
type: 'user',
name: 'Admin User',
owner: 'admin@example.com',
},
permissions: [],
},
validate: {
app_id: (value) => value.length < 1 ? 'Application is required' : null,
permissions: (value) => value.length < 1 ? 'At least one permission is required' : null,
},
});
const availablePermissions = [
'app.read',
'app.write',
'app.delete',
'token.read',
'token.create',
'token.revoke',
'repo.read',
'repo.write',
'repo.admin',
'permission.read',
'permission.write',
];
useEffect(() => {
loadApplications();
loadAllTokens();
}, []);
const loadApplications = async () => {
try {
const response = await apiService.getApplications(100, 0);
setApplications(response.data);
} catch (error) {
console.error('Failed to load applications:', error);
}
};
const loadAllTokens = async () => {
try {
setLoading(true);
const appsResponse = await apiService.getApplications(100, 0);
const allTokens: TokenWithApp[] = [];
// Load tokens for each application
for (const app of appsResponse.data) {
try {
const tokensResponse = await apiService.getTokensForApplication(app.app_id, 100, 0);
const tokensWithApp = tokensResponse.data.map(token => ({
...token,
app,
}));
allTokens.push(...tokensWithApp);
} catch (error) {
// Some apps might not have tokens, that's OK
}
}
setTokens(allTokens);
} catch (error) {
console.error('Failed to load tokens:', error);
notifications.show({
title: 'Error',
message: 'Failed to load tokens',
color: 'red',
});
} finally {
setLoading(false);
}
};
const handleSubmit = async (values: CreateTokenRequest & { app_id: string }) => {
try {
const { app_id, ...tokenData } = values;
const response = await apiService.createToken(app_id, tokenData);
setCreatedToken(response);
setModalOpen(false);
setTokenModalOpen(true);
form.reset();
loadAllTokens();
notifications.show({
title: 'Success',
message: 'Token created successfully',
color: 'green',
});
} catch (error) {
console.error('Failed to create token:', error);
notifications.show({
title: 'Error',
message: 'Failed to create token',
color: 'red',
});
}
};
const handleDelete = async (tokenId: string) => {
if (window.confirm('Are you sure you want to delete this token? This action cannot be undone.')) {
try {
await apiService.deleteToken(tokenId);
notifications.show({
title: 'Success',
message: 'Token deleted successfully',
color: 'green',
});
loadAllTokens();
} catch (error) {
console.error('Failed to delete token:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete token',
color: 'red',
});
}
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
notifications.show({
title: 'Copied',
message: 'Copied to clipboard',
color: 'blue',
});
};
const rows = tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Group gap="xs">
<IconKey size={16} color="blue" />
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{token.id.substring(0, 8)}...
</Text>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light" color="blue">
{token.app?.app_id || 'Unknown'}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{token.owner.name} ({token.owner.owner})
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color="green">
{token.type}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">
{dayjs(token.created_at).format('MMM DD, YYYY')}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
onClick={() => copyToClipboard(token.id)}
title="Copy Token ID"
>
<IconCopy size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDelete(token.id)}
title="Delete Token"
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack gap="lg">
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
API Tokens
</Title>
<Text c="dimmed">
Manage static API tokens for your applications
</Text>
</div>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setModalOpen(true);
}}
disabled={applications.length === 0}
>
Create Token
</Button>
</Group>
{applications.length === 0 && (
<Alert
icon={<IconAlertCircle size={16} />}
title="No Applications Found"
color="yellow"
>
You need to create at least one application before you can create tokens.
</Alert>
)}
{loading ? (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
<Text>Loading tokens...</Text>
</Stack>
) : tokens.length === 0 ? (
<Card shadow="sm" radius="md" withBorder p="xl">
<Stack align="center" gap="md">
<IconKey size={48} color="gray" />
<div style={{ textAlign: 'center' }}>
<Text fw={500} mb="xs">
No tokens found
</Text>
<Text size="sm" c="dimmed">
Create your first API token to start using the key management system
</Text>
</div>
{applications.length > 0 && (
<Button
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setModalOpen(true);
}}
>
Create Token
</Button>
)}
</Stack>
</Card>
) : (
<Card shadow="sm" radius="md" withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Token ID</Table.Th>
<Table.Th>Application</Table.Th>
<Table.Th>Owner</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Card>
)}
{/* Create Token Modal */}
<Modal
opened={modalOpen}
onClose={() => {
setModalOpen(false);
form.reset();
}}
title="Create New Token"
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label="Application"
placeholder="Select an application"
required
data={applications.map(app => ({
value: app.app_id,
label: `${app.app_id} (${app.type.join(', ')})`,
}))}
{...form.getInputProps('app_id')}
/>
<MultiSelect
label="Permissions"
placeholder="Select permissions for this token"
required
data={availablePermissions.map(perm => ({
value: perm,
label: perm,
}))}
{...form.getInputProps('permissions')}
searchable
/>
<TextInput
label="Owner Name"
placeholder="Token owner name"
{...form.getInputProps('owner.name')}
/>
<TextInput
label="Owner Email"
placeholder="owner@example.com"
{...form.getInputProps('owner.owner')}
/>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
onClick={() => {
setModalOpen(false);
form.reset();
}}
>
Cancel
</Button>
<Button type="submit">Create Token</Button>
</Group>
</Stack>
</form>
</Modal>
{/* Token Created Modal */}
<Modal
opened={tokenModalOpen}
onClose={() => {
setTokenModalOpen(false);
setCreatedToken(null);
}}
title="Token Created Successfully"
size="lg"
>
{createdToken && (
<Stack gap="md">
<Alert
icon={<IconCheck size={16} />}
title="Important"
color="green"
>
This is the only time you will see the full token. Make sure to copy and store it securely.
</Alert>
<div>
<Text fw={500} mb="xs">Token:</Text>
<Group gap="xs">
<Code
block
style={{
fontSize: '12px',
wordBreak: 'break-all',
flex: 1,
}}
>
{createdToken.token}
</Code>
<ActionIcon
variant="subtle"
color="blue"
onClick={() => copyToClipboard(createdToken.token)}
title="Copy Token"
>
<IconCopy size={16} />
</ActionIcon>
</Group>
</div>
<Divider />
<Group justify="space-between">
<Text fw={500}>Token ID:</Text>
<Group gap="xs">
<Text size="sm" style={{ fontFamily: 'monospace' }}>
{createdToken.id}
</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => copyToClipboard(createdToken.id)}
>
<IconCopy size={12} />
</ActionIcon>
</Group>
</Group>
<Group justify="space-between" align="flex-start">
<Text fw={500}>Permissions:</Text>
<Stack gap="xs" align="flex-end">
{createdToken.permissions.map((perm) => (
<Badge key={perm} variant="light" size="sm">
{perm}
</Badge>
))}
</Stack>
</Group>
<Group justify="space-between">
<Text fw={500}>Created:</Text>
<Text size="sm">
{dayjs(createdToken.created_at).format('MMM DD, YYYY HH:mm')}
</Text>
</Group>
<Group justify="flex-end" mt="lg">
<Button
onClick={() => {
setTokenModalOpen(false);
setCreatedToken(null);
}}
>
Close
</Button>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
};
export default Tokens;

19
kms/web/src/index.tsx Normal file
View File

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { MantineProvider } from '@mantine/core';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<MantineProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</MantineProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,273 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
// Types based on the KMS API
export interface Application {
app_id: string;
app_link: string;
type: string[];
callback_url: string;
hmac_key: string;
token_prefix?: string;
token_renewal_duration: number;
max_token_duration: number;
owner: {
type: string;
name: string;
owner: string;
};
created_at: string;
updated_at: string;
}
export interface StaticToken {
id: string;
app_id: string;
owner: {
type: string;
name: string;
owner: string;
};
type: string;
created_at: string;
updated_at: string;
}
export interface CreateApplicationRequest {
app_id: string;
app_link: string;
type: string[];
callback_url: string;
token_prefix?: string;
token_renewal_duration: string;
max_token_duration: string;
owner: {
type: string;
name: string;
owner: string;
};
}
export interface CreateTokenRequest {
owner: {
type: string;
name: string;
owner: string;
};
permissions: string[];
}
export interface CreateTokenResponse {
id: string;
token: string;
permissions: string[];
created_at: string;
}
export interface PaginatedResponse<T> {
data: T[];
limit: number;
offset: number;
count: number;
}
export interface VerifyRequest {
app_id: string;
user_id?: string;
token: string;
permissions?: string[];
}
export interface VerifyResponse {
valid: boolean;
permitted: boolean;
user_id?: string;
permissions: string[];
permission_results?: Record<string, boolean>;
expires_at?: string;
max_valid_at?: string;
token_type: string;
claims?: Record<string, string>;
error?: string;
}
export interface AuditEvent {
id: string;
type: string;
status: string;
timestamp: string;
actor_id?: string;
actor_ip?: string;
user_agent?: string;
resource_id?: string;
resource_type?: string;
action: string;
description: string;
details?: Record<string, any>;
request_id?: string;
session_id?: string;
}
export interface AuditQueryParams {
event_types?: string[];
statuses?: string[];
actor_id?: string;
resource_id?: string;
resource_type?: string;
start_time?: string;
end_time?: string;
limit?: number;
offset?: number;
order_by?: string;
order_desc?: boolean;
}
export interface AuditResponse {
events: AuditEvent[];
total: number;
limit: number;
offset: number;
}
export interface AuditStats {
total_events: number;
by_type: Record<string, number>;
by_severity: Record<string, number>;
by_status: Record<string, number>;
by_time?: Record<string, number>;
}
export interface AuditStatsParams {
event_types?: string[];
start_time?: string;
end_time?: string;
group_by?: string;
}
class ApiService {
private api: AxiosInstance;
private baseURL: string;
constructor() {
this.baseURL = process.env.REACT_APP_API_URL || 'http://localhost:8080';
this.api = axios.create({
baseURL: this.baseURL,
headers: {
'Content-Type': 'application/json',
},
});
// Add request interceptor to include user email header
this.api.interceptors.request.use((config) => {
// For demo purposes, we'll use a default user
config.headers['X-User-Email'] = 'admin@example.com';
return config;
});
// Add response interceptor for error handling
this.api.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error);
return Promise.reject(error);
}
);
}
// Applications
async getApplications(limit: number = 50, offset: number = 0): Promise<PaginatedResponse<Application>> {
const response = await this.api.get(`/api/applications?limit=${limit}&offset=${offset}`);
return response.data;
}
async getApplication(appId: string): Promise<Application> {
const response = await this.api.get(`/api/applications/${appId}`);
return response.data;
}
async createApplication(data: CreateApplicationRequest): Promise<Application> {
const response = await this.api.post('/api/applications', data);
return response.data;
}
async updateApplication(appId: string, data: Partial<CreateApplicationRequest>): Promise<Application> {
const response = await this.api.put(`/api/applications/${appId}`, data);
return response.data;
}
async deleteApplication(appId: string): Promise<void> {
await this.api.delete(`/api/applications/${appId}`);
}
// Tokens
async getTokensForApplication(appId: string, limit: number = 50, offset: number = 0): Promise<PaginatedResponse<StaticToken>> {
const response = await this.api.get(`/api/applications/${appId}/tokens?limit=${limit}&offset=${offset}`);
return response.data;
}
async createToken(appId: string, data: CreateTokenRequest): Promise<CreateTokenResponse> {
const response = await this.api.post(`/api/applications/${appId}/tokens`, data);
return response.data;
}
async deleteToken(tokenId: string): Promise<void> {
await this.api.delete(`/api/tokens/${tokenId}`);
}
// Token verification
async verifyToken(data: VerifyRequest): Promise<VerifyResponse> {
const response = await this.api.post('/api/verify', data);
return response.data;
}
// Audit endpoints
async getAuditEvents(params?: AuditQueryParams): Promise<AuditResponse> {
const queryString = new URLSearchParams();
if (params) {
if (params.event_types?.length) {
params.event_types.forEach(type => queryString.append('event_types', type));
}
if (params.statuses?.length) {
params.statuses.forEach(status => queryString.append('statuses', status));
}
if (params.actor_id) queryString.set('actor_id', params.actor_id);
if (params.resource_id) queryString.set('resource_id', params.resource_id);
if (params.resource_type) queryString.set('resource_type', params.resource_type);
if (params.start_time) queryString.set('start_time', params.start_time);
if (params.end_time) queryString.set('end_time', params.end_time);
if (params.limit) queryString.set('limit', params.limit.toString());
if (params.offset) queryString.set('offset', params.offset.toString());
if (params.order_by) queryString.set('order_by', params.order_by);
if (params.order_desc !== undefined) queryString.set('order_desc', params.order_desc.toString());
}
const url = `/api/audit/events${queryString.toString() ? '?' + queryString.toString() : ''}`;
const response = await this.api.get(url);
return response.data;
}
async getAuditEvent(eventId: string): Promise<AuditEvent> {
const response = await this.api.get(`/api/audit/events/${eventId}`);
return response.data;
}
async getAuditStats(params?: AuditStatsParams): Promise<AuditStats> {
const queryString = new URLSearchParams();
if (params) {
if (params.event_types?.length) {
params.event_types.forEach(type => queryString.append('event_types', type));
}
if (params.start_time) queryString.set('start_time', params.start_time);
if (params.end_time) queryString.set('end_time', params.end_time);
if (params.group_by) queryString.set('group_by', params.group_by);
}
const url = `/api/audit/stats${queryString.toString() ? '?' + queryString.toString() : ''}`;
const response = await this.api.get(url);
return response.data;
}
}
export const apiService = new ApiService();

85
kms/web/webpack.config.js Normal file
View File

@ -0,0 +1,85 @@
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const webpack = require('webpack');
module.exports = {
mode: 'development',
entry: './src/index.tsx',
devServer: {
port: 3002,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx'],
},
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-react',
'@babel/preset-typescript',
],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'kms',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx',
},
shared: {
react: {
singleton: true,
requiredVersion: '^18.2.0',
eager: false,
},
'react-dom': {
singleton: true,
requiredVersion: '^18.2.0',
eager: false,
},
'@mantine/core': {
singleton: true,
requiredVersion: '^7.0.0',
eager: false,
},
'@mantine/hooks': {
singleton: true,
requiredVersion: '^7.0.0',
eager: false,
},
'@mantine/notifications': {
singleton: true,
requiredVersion: '^7.0.0',
eager: false,
},
'@tabler/icons-react': {
singleton: true,
requiredVersion: '^2.40.0',
eager: false,
},
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
new webpack.DefinePlugin({
'process.env': JSON.stringify(process.env),
}),
],
};

2
web/dist/main.js vendored

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@ import { IconAlertCircle } from '@tabler/icons-react';
import Breadcrumbs from './Breadcrumbs';
const DemoApp = React.lazy(() => import('demo/App'));
const KMSApp = React.lazy(() => import('kms/App'));
const AppLoader: React.FC = () => {
const { appName } = useParams<{ appName: string }>();
@ -37,6 +38,12 @@ const AppLoader: React.FC = () => {
<DemoApp />
</Suspense>
);
case 'kms':
return (
<Suspense fallback={<LoadingFallback />}>
<KMSApp />
</Suspense>
);
default:
return (
<ErrorFallback

View File

@ -7,6 +7,7 @@ import {
IconDashboard,
IconChartLine,
IconStar,
IconKey,
} from '@tabler/icons-react';
const Navigation: React.FC = () => {
@ -37,6 +38,11 @@ const Navigation: React.FC = () => {
icon: IconDashboard,
path: '/app/demo',
},
{
label: 'Key Management',
icon: IconKey,
path: '/app/kms',
},
{
label: 'Analytics',
icon: IconChartLine,

View File

@ -46,6 +46,7 @@ module.exports = {
name: 'shell',
remotes: {
demo: 'demo@http://localhost:3001/remoteEntry.js',
kms: 'kms@http://localhost:3002/remoteEntry.js',
},
shared: {
react: {