sidebar fix

This commit is contained in:
2025-08-31 23:15:50 -04:00
parent 1430c97ae7
commit 23dfc171b8
10 changed files with 1836 additions and 171 deletions

View File

@ -0,0 +1,241 @@
import React, { useEffect } from 'react';
import {
Paper,
TextInput,
MultiSelect,
Button,
Group,
Stack,
Title,
ActionIcon,
ScrollArea,
Box,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
interface ApplicationSidebarProps {
opened: boolean;
onClose: () => void;
onSuccess: () => void;
editingApp?: Application | null;
}
const ApplicationSidebar: React.FC<ApplicationSidebarProps> = ({
opened,
onClose,
onSuccess,
editingApp,
}) => {
const isEditing = !!editingApp;
const appTypeOptions = [
{ value: 'static', label: 'Static Token App' },
{ value: 'user', label: 'User Token App' },
];
const form = useForm<CreateApplicationRequest>({
initialValues: {
app_id: '',
app_link: '',
type: [],
callback_url: '',
token_prefix: '',
token_renewal_duration: '24h',
max_token_duration: '168h',
owner: {
type: 'individual',
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,
},
});
const parseDuration = (duration: string): number => {
// Convert duration string like "24h" to seconds
const match = duration.match(/^(\d+)([hmd]?)$/);
if (!match) return 86400; // Default to 24h in seconds
const value = parseInt(match[1]);
const unit = match[2] || 'h';
switch (unit) {
case 'm': return value * 60; // minutes to seconds
case 'h': return value * 3600; // hours to seconds
case 'd': return value * 86400; // days to seconds
default: return value * 3600; // default to hours
}
};
// Update form values when editingApp changes
useEffect(() => {
if (editingApp) {
form.setValues({
app_id: editingApp.app_id || '',
app_link: editingApp.app_link || '',
type: editingApp.type || [],
callback_url: editingApp.callback_url || '',
token_prefix: editingApp.token_prefix || '',
token_renewal_duration: editingApp.token_renewal_duration || '24h',
max_token_duration: editingApp.max_token_duration || '168h',
owner: {
type: editingApp.owner?.type || 'individual',
name: editingApp.owner?.name || 'Admin User',
owner: editingApp.owner?.owner || 'admin@example.com',
},
});
} else {
// Reset to default values when not editing
form.reset();
}
}, [editingApp, opened]);
const handleSubmit = async (values: typeof form.values) => {
try {
const submitData = {
...values,
token_renewal_duration_seconds: parseDuration(values.token_renewal_duration),
max_token_duration_seconds: parseDuration(values.max_token_duration),
};
if (isEditing && editingApp) {
await apiService.updateApplication(editingApp.app_id, submitData);
notifications.show({
title: 'Success',
message: 'Application updated successfully',
color: 'green',
});
} else {
await apiService.createApplication(submitData);
notifications.show({
title: 'Success',
message: 'Application created successfully',
color: 'green',
});
}
onSuccess();
onClose();
form.reset();
} catch (error) {
console.error('Error saving application:', error);
notifications.show({
title: 'Error',
message: `Failed to ${isEditing ? 'update' : 'create'} application`,
color: 'red',
});
}
};
return (
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-450px',
bottom: 0,
width: '450px',
zIndex: 1000,
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
transition: 'right 0.3s ease',
}}
>
{/* Header */}
<Group justify="space-between" p="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
<Title order={4}>
{isEditing ? 'Edit Application' : 'Create New Application'}
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label="Application ID"
placeholder="my-app-id"
required
{...form.getInputProps('app_id')}
disabled={isEditing}
/>
<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="light"
onClick={onClose}
>
Cancel
</Button>
<Button type="submit">
{isEditing ? 'Update Application' : 'Create Application'}
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
);
};
export default ApplicationSidebar;

View File

@ -29,12 +29,13 @@ import {
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
import ApplicationSidebar from './ApplicationSidebar';
import dayjs from 'dayjs';
const Applications: React.FC = () => {
const [applications, setApplications] = useState<Application[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [editingApp, setEditingApp] = useState<Application | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedApp, setSelectedApp] = useState<Application | null>(null);
@ -122,7 +123,7 @@ const Applications: React.FC = () => {
color: 'green',
});
}
setModalOpen(false);
setSidebarOpen(false);
setEditingApp(null);
form.reset();
loadApplications();
@ -148,7 +149,7 @@ const Applications: React.FC = () => {
max_token_duration: `${app.max_token_duration / 3600}h`,
owner: app.owner,
});
setModalOpen(true);
setSidebarOpen(true);
};
const handleDelete = async (appId: string) => {
@ -247,7 +248,13 @@ const Applications: React.FC = () => {
));
return (
<Stack gap="lg">
<Stack
gap="lg"
style={{
transition: 'margin-right 0.3s ease',
marginRight: sidebarOpen ? '450px' : '0',
}}
>
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
@ -259,7 +266,7 @@ const Applications: React.FC = () => {
onClick={() => {
setEditingApp(null);
form.reset();
setModalOpen(true);
setSidebarOpen(true);
}}
>
New Application
@ -288,7 +295,7 @@ const Applications: React.FC = () => {
onClick={() => {
setEditingApp(null);
form.reset();
setModalOpen(true);
setSidebarOpen(true);
}}
>
Create Application
@ -312,86 +319,17 @@ const Applications: React.FC = () => {
</Card>
)}
{/* Create/Edit Modal */}
<Modal
opened={modalOpen}
<ApplicationSidebar
opened={sidebarOpen}
onClose={() => {
setModalOpen(false);
setSidebarOpen(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>
onSuccess={() => {
loadApplications();
}}
editingApp={editingApp}
/>
{/* Detail Modal */}
<Modal

View File

@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import {
Paper,
TextInput,
Button,
Group,
Stack,
Title,
ActionIcon,
ScrollArea,
Box,
Select,
Alert,
Code,
Divider,
Text,
Modal,
} from '@mantine/core';
import { IconX, IconCheck, IconCopy } from '@tabler/icons-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import PermissionTree from './PermissionTree';
import {
apiService,
Application,
CreateTokenRequest,
CreateTokenResponse,
} from '../services/apiService';
interface TokenSidebarProps {
opened: boolean;
onClose: () => void;
onSuccess: () => void;
applications: Application[];
}
const TokenSidebar: React.FC<TokenSidebarProps> = ({
opened,
onClose,
onSuccess,
applications,
}) => {
const [tokenModalOpen, setTokenModalOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null);
const form = useForm<CreateTokenRequest & { app_id: string }>({
initialValues: {
app_id: '',
owner: {
type: 'individual',
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,
},
});
// Reset form when sidebar opens
useEffect(() => {
if (opened) {
form.reset();
}
}, [opened]);
const handleSubmit = async (values: CreateTokenRequest & { app_id: string }) => {
try {
const { app_id, ...tokenData } = values;
const response = await apiService.createToken(app_id, tokenData);
setCreatedToken(response);
setTokenModalOpen(true);
form.reset();
onSuccess();
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 copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
notifications.show({
title: 'Copied',
message: 'Copied to clipboard',
color: 'blue',
});
};
const handleTokenModalClose = () => {
setTokenModalOpen(false);
setCreatedToken(null);
onClose();
};
return (
<>
<Paper
style={{
position: 'fixed',
top: 60, // Below header
right: opened ? 0 : '-450px',
bottom: 0,
width: '450px',
zIndex: 1000,
borderRadius: 0,
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
transition: 'right 0.3s ease',
}}
>
{/* Header */}
<Group justify="space-between" p="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
<Title order={4}>
Create New Token
</Title>
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }}>
<Box p="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')}
/>
<div>
<Text size="sm" fw={500} mb="xs">
Required Permissions
</Text>
<Text size="xs" c="dimmed" mb="md">
Select the permissions this token should have
</Text>
<PermissionTree
permissions={form.values.permissions}
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
/>
</div>
<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="light"
onClick={onClose}
>
Cancel
</Button>
<Button
type="submit"
disabled={applications.length === 0}
>
Create Token
</Button>
</Group>
</Stack>
</form>
</Box>
</ScrollArea>
</Paper>
{/* Token Created Modal */}
<Modal
opened={tokenModalOpen}
onClose={handleTokenModalClose}
title="Token Created Successfully"
size="lg"
closeOnEscape={false}
closeOnClickOutside={false}
>
<Stack gap="md">
<Alert
icon={<IconCheck size={16} />}
title="Success!"
color="green"
>
Your token has been created successfully. Please copy and store it securely as you won't be able to see it again.
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
Token:
</Text>
<Group gap="xs">
<Code
block
style={{
flex: 1,
wordBreak: 'break-all',
whiteSpace: 'pre-wrap',
}}
>
{createdToken?.token}
</Code>
<ActionIcon
variant="light"
onClick={() => createdToken?.token && copyToClipboard(createdToken.token)}
title="Copy Token"
>
<IconCopy size={16} />
</ActionIcon>
</Group>
</div>
{createdToken?.prefix && (
<div>
<Text size="sm" fw={500} mb="xs">
Token Prefix:
</Text>
<Code>{createdToken.prefix}</Code>
</div>
)}
<Divider />
<div>
<Text size="sm" fw={500} mb="xs">
Token Details:
</Text>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm">Token ID:</Text>
<Code>{createdToken?.id}</Code>
</Group>
<Group justify="space-between">
<Text size="sm">Type:</Text>
<Code>{createdToken?.type}</Code>
</Group>
{createdToken?.permissions && (
<div>
<Text size="sm" mb="xs">Permissions:</Text>
<Group gap="xs">
{createdToken.permissions.map((permission) => (
<Code key={permission} size="xs">
{permission}
</Code>
))}
</Group>
</div>
)}
</Stack>
</div>
<Group justify="flex-end" mt="md">
<Button onClick={handleTokenModalClose}>
Done
</Button>
</Group>
</Stack>
</Modal>
</>
);
};
export default TokenSidebar;

View File

@ -38,6 +38,7 @@ import {
CreateTokenRequest,
CreateTokenResponse,
} from '../services/apiService';
import TokenSidebar from './TokenSidebar';
import dayjs from 'dayjs';
interface TokenWithApp extends StaticToken {
@ -48,7 +49,7 @@ 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 [sidebarOpen, setSidebarOpen] = useState(false);
const [tokenModalOpen, setTokenModalOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<CreateTokenResponse | null>(null);
@ -134,7 +135,7 @@ const Tokens: React.FC = () => {
const { app_id, ...tokenData } = values;
const response = await apiService.createToken(app_id, tokenData);
setCreatedToken(response);
setModalOpen(false);
setSidebarOpen(false);
setTokenModalOpen(true);
form.reset();
loadAllTokens();
@ -237,7 +238,13 @@ const Tokens: React.FC = () => {
));
return (
<Stack gap="lg">
<Stack
gap="lg"
style={{
transition: 'margin-right 0.3s ease',
marginRight: sidebarOpen ? '450px' : '0',
}}
>
<Group justify="space-between">
<div>
<Title order={2} mb="xs">
@ -248,7 +255,7 @@ const Tokens: React.FC = () => {
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setModalOpen(true);
setSidebarOpen(true);
}}
disabled={applications.length === 0}
>
@ -288,7 +295,7 @@ const Tokens: React.FC = () => {
leftSection={<IconPlus size={16} />}
onClick={() => {
form.reset();
setModalOpen(true);
setSidebarOpen(true);
}}
>
Create Token
@ -314,61 +321,17 @@ const Tokens: React.FC = () => {
</Card>
)}
{/* Create Token Modal */}
<Modal
opened={modalOpen}
<TokenSidebar
opened={sidebarOpen}
onClose={() => {
setModalOpen(false);
setSidebarOpen(false);
form.reset();
}}
title="Create New Token"
size="lg"
>
<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')}
/>
<PermissionTree
permissions={form.values.permissions}
onChange={(permissions) => form.setFieldValue('permissions', permissions)}
/>
<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>
onSuccess={() => {
loadAllTokens();
}}
applications={applications}
/>
{/* Token Created Modal */}
<Modal