unified UI thing
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
1
kms/web/.gitignore
vendored
Normal file
1
kms/web/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
1
kms/web/dist/211.js
vendored
Normal file
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
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
21
kms/web/dist/265.js.LICENSE.txt
vendored
Normal 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
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
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
9
kms/web/dist/540.js.LICENSE.txt
vendored
Normal 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
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
9
kms/web/dist/63.js.LICENSE.txt
vendored
Normal 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
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
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
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
9
kms/web/dist/875.js.LICENSE.txt
vendored
Normal 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
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
19
kms/web/dist/961.js.LICENSE.txt
vendored
Normal 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
1
kms/web/dist/index.html
vendored
Normal 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
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
21
kms/web/dist/main.js.LICENSE.txt
vendored
Normal 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
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
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
38
kms/web/package.json
Normal 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
14
kms/web/public/index.html
Normal 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
124
kms/web/src/App.tsx
Normal 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;
|
||||
449
kms/web/src/components/Applications.tsx
Normal file
449
kms/web/src/components/Applications.tsx
Normal 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;
|
||||
460
kms/web/src/components/Audit.tsx
Normal file
460
kms/web/src/components/Audit.tsx
Normal 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;
|
||||
202
kms/web/src/components/Dashboard.tsx
Normal file
202
kms/web/src/components/Dashboard.tsx
Normal 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;
|
||||
373
kms/web/src/components/TokenTester.tsx
Normal file
373
kms/web/src/components/TokenTester.tsx
Normal 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;
|
||||
479
kms/web/src/components/Tokens.tsx
Normal file
479
kms/web/src/components/Tokens.tsx
Normal 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
19
kms/web/src/index.tsx
Normal 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>
|
||||
);
|
||||
273
kms/web/src/services/apiService.ts
Normal file
273
kms/web/src/services/apiService.ts
Normal 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
85
kms/web/webpack.config.js
Normal 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
2
web/dist/main.js
vendored
File diff suppressed because one or more lines are too long
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user