mirror of
https://github.com/apidoorman/doorman.git
synced 2026-04-28 04:10:24 -05:00
UI overhaul
This commit is contained in:
Generated
+20
@@ -8,7 +8,9 @@
|
||||
"name": "doorman",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": ">=15.3.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
@@ -2061,6 +2063,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@@ -4162,6 +4173,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.460.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz",
|
||||
"integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": ">=15.3.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,456 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import '../apis.css';
|
||||
import './add-api.css';
|
||||
|
||||
interface CreateApiData {
|
||||
api_name: string;
|
||||
api_version: string;
|
||||
api_description: string;
|
||||
api_allowed_roles: string[];
|
||||
api_allowed_groups: string[];
|
||||
api_servers: string[];
|
||||
api_type: string;
|
||||
api_allowed_retry_count: number;
|
||||
api_authorization_field_swap?: string;
|
||||
api_allowed_headers?: string[];
|
||||
api_tokens_enabled: boolean;
|
||||
api_token_group?: string;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logs' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
const AddApiPage = () => {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState<CreateApiData>({
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
api_name: '',
|
||||
api_version: '',
|
||||
api_description: '',
|
||||
api_allowed_roles: [],
|
||||
api_allowed_groups: [],
|
||||
api_servers: [],
|
||||
api_type: 'REST',
|
||||
api_allowed_retry_count: 0,
|
||||
api_tokens_enabled: false
|
||||
});
|
||||
const [newRole, setNewRole] = useState('');
|
||||
const [newGroup, setNewGroup] = useState('');
|
||||
const [newServer, setNewServer] = useState('');
|
||||
const [newHeader, setNewHeader] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleInputChange = (field: keyof CreateApiData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addRole = () => {
|
||||
if (newRole.trim()) {
|
||||
setFormData(prev => ({ ...prev, api_allowed_roles: [...prev.api_allowed_roles, newRole.trim()] }));
|
||||
setNewRole('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeRole = (index: number) => {
|
||||
setFormData(prev => ({ ...prev, api_allowed_roles: prev.api_allowed_roles.filter((_, i) => i !== index) }));
|
||||
};
|
||||
|
||||
const addGroup = () => {
|
||||
if (newGroup.trim()) {
|
||||
setFormData(prev => ({ ...prev, api_allowed_groups: [...prev.api_allowed_groups, newGroup.trim()] }));
|
||||
setNewGroup('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeGroup = (index: number) => {
|
||||
setFormData(prev => ({ ...prev, api_allowed_groups: prev.api_allowed_groups.filter((_, i) => i !== index) }));
|
||||
};
|
||||
|
||||
const addServer = () => {
|
||||
if (newServer.trim()) {
|
||||
setFormData(prev => ({ ...prev, api_servers: [...prev.api_servers, newServer.trim()] }));
|
||||
setNewServer('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeServer = (index: number) => {
|
||||
setFormData(prev => ({ ...prev, api_servers: prev.api_servers.filter((_, i) => i !== index) }));
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
if (newHeader.trim()) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
api_allowed_headers: [...(prev.api_allowed_headers || []), newHeader.trim()]
|
||||
}));
|
||||
setNewHeader('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
api_allowed_headers: prev.api_allowed_headers?.filter((_, i) => i !== index) || []
|
||||
}));
|
||||
};
|
||||
api_path: '',
|
||||
api_description: '',
|
||||
validation_enabled: false
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
if (!formData.api_name || !formData.api_version || !formData.api_description) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('http://localhost:3002/platform/api/', {
|
||||
const response = await fetch('http://localhost:3002/platform/api', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to create API');
|
||||
if (response.ok) {
|
||||
router.push('/apis')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.detail || 'Failed to create API')
|
||||
}
|
||||
|
||||
// Redirect to APIs list after successful creation
|
||||
router.push('/apis');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create API');
|
||||
setError('Network error. Please try again.')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="apis-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="apis-root">
|
||||
<aside className="apis-sidebar">
|
||||
<div className="apis-sidebar-title">Menu</div>
|
||||
<ul className="apis-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`apis-sidebar-item${idx === 1 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`apis-sidebar-item${idx === 1 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="apis-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="apis-main">
|
||||
<div className="apis-header-row">
|
||||
<div className="add-api-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to APIs
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Add New API</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Create a new API endpoint for your gateway
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/apis" className="btn btn-secondary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to APIs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div className="card max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="api_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Name *
|
||||
</label>
|
||||
<input
|
||||
id="api_name"
|
||||
name="api_name"
|
||||
type="text"
|
||||
required
|
||||
className="input"
|
||||
placeholder="e.g., user-service"
|
||||
value={formData.api_name}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
A unique identifier for your API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="api_version" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Version *
|
||||
</label>
|
||||
<input
|
||||
id="api_version"
|
||||
name="api_version"
|
||||
type="text"
|
||||
required
|
||||
className="input"
|
||||
placeholder="e.g., v1"
|
||||
value={formData.api_version}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
API version (e.g., v1, v2, beta)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="api_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Type *
|
||||
</label>
|
||||
<select
|
||||
id="api_type"
|
||||
name="api_type"
|
||||
required
|
||||
className="input"
|
||||
value={formData.api_type}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="REST">REST</option>
|
||||
<option value="GraphQL">GraphQL</option>
|
||||
<option value="gRPC">gRPC</option>
|
||||
<option value="SOAP">SOAP</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The protocol type for this API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="api_path" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Path *
|
||||
</label>
|
||||
<input
|
||||
id="api_path"
|
||||
name="api_path"
|
||||
type="text"
|
||||
required
|
||||
className="input"
|
||||
placeholder="e.g., https://api.example.com"
|
||||
value={formData.api_path}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The base URL or path for your API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="api_description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="api_description"
|
||||
name="api_description"
|
||||
rows={4}
|
||||
className="input resize-none"
|
||||
placeholder="Describe what this API does..."
|
||||
value={formData.api_description}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Optional description of the API's purpose
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="validation_enabled"
|
||||
name="validation_enabled"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
checked={formData.validation_enabled}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="validation_enabled" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Enable request validation
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Creating API...
|
||||
</div>
|
||||
) : (
|
||||
'Create API'
|
||||
)}
|
||||
</button>
|
||||
<h1 className="apis-title">Add New API</h1>
|
||||
<Link href="/apis" className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-api-content">
|
||||
<form onSubmit={handleSubmit} className="add-api-form">
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">API Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.api_name}
|
||||
onChange={(e) => handleInputChange('api_name', e.target.value)}
|
||||
placeholder="Enter API name"
|
||||
minLength={1}
|
||||
maxLength={25}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">API Version *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.api_version}
|
||||
onChange={(e) => handleInputChange('api_version', e.target.value)}
|
||||
placeholder="Enter API version"
|
||||
minLength={1}
|
||||
maxLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">API Type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.api_type}
|
||||
onChange={(e) => handleInputChange('api_type', e.target.value)}
|
||||
>
|
||||
<option value="REST">REST</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Retry Count</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.api_allowed_retry_count}
|
||||
onChange={(e) => handleInputChange('api_allowed_retry_count', parseInt(e.target.value) || 0)}
|
||||
min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description *</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
value={formData.api_description}
|
||||
onChange={(e) => handleInputChange('api_description', e.target.value)}
|
||||
placeholder="Enter API description"
|
||||
minLength={1}
|
||||
maxLength={127}
|
||||
required
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Allowed Roles</h2>
|
||||
<div className="groups-container">
|
||||
<div className="groups-list">
|
||||
{formData.api_allowed_roles.map((role, index) => (
|
||||
<span key={index} className="group-tag">
|
||||
{role}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-group-btn"
|
||||
onClick={() => removeRole(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter role name"
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addRole())}
|
||||
/>
|
||||
<button type="button" className="add-button" onClick={addRole}>
|
||||
Add Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Allowed Groups</h2>
|
||||
<div className="groups-container">
|
||||
<div className="groups-list">
|
||||
{formData.api_allowed_groups.map((group, index) => (
|
||||
<span key={index} className="group-tag">
|
||||
{group}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-group-btn"
|
||||
onClick={() => removeGroup(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter group name"
|
||||
value={newGroup}
|
||||
onChange={(e) => setNewGroup(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addGroup())}
|
||||
/>
|
||||
<button type="button" className="add-button" onClick={addGroup}>
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Backend Servers</h2>
|
||||
<div className="groups-container">
|
||||
<div className="groups-list">
|
||||
{formData.api_servers.map((server, index) => (
|
||||
<span key={index} className="group-tag">
|
||||
{server}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-group-btn"
|
||||
onClick={() => removeServer(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter server URL (e.g., http://localhost:8080)"
|
||||
value={newServer}
|
||||
onChange={(e) => setNewServer(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addServer())}
|
||||
/>
|
||||
<button type="button" className="add-button" onClick={addServer}>
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Authorization & Headers</h2>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Authorization Field Swap</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.api_authorization_field_swap || ''}
|
||||
onChange={(e) => handleInputChange('api_authorization_field_swap', e.target.value)}
|
||||
placeholder="e.g., backend-auth-header"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Token Group</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.api_token_group || ''}
|
||||
onChange={(e) => handleInputChange('api_token_group', e.target.value)}
|
||||
placeholder="e.g., ai-group-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Tokens Enabled</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.api_tokens_enabled ? 'true' : 'false'}
|
||||
onChange={(e) => handleInputChange('api_tokens_enabled', e.target.value === 'true')}
|
||||
>
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Allowed Headers</h2>
|
||||
<div className="groups-container">
|
||||
<div className="groups-list">
|
||||
{(formData.api_allowed_headers || []).map((header, index) => (
|
||||
<span key={index} className="group-tag">
|
||||
{header}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-group-btn"
|
||||
onClick={() => removeHeader(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter header name (e.g., Content-Type)"
|
||||
value={newHeader}
|
||||
onChange={(e) => setNewHeader(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addHeader())}
|
||||
/>
|
||||
<button type="button" className="add-button" onClick={addHeader}>
|
||||
Add Header
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="cancel-button" onClick={handleBack}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="save-button" disabled={loading}>
|
||||
{loading ? 'Creating API...' : 'Create API'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddApiPage;
|
||||
export default AddApiPage
|
||||
+196
-172
@@ -1,71 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, Key, ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import './apis.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface API {
|
||||
api_version: ReactNode;
|
||||
api_type: ReactNode;
|
||||
api_description: ReactNode;
|
||||
api_path: ReactNode;
|
||||
api_id: Key | null | undefined;
|
||||
api_name: ReactNode;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
status: string;
|
||||
endpoints: number;
|
||||
lastUpdated: string;
|
||||
api_version: React.ReactNode
|
||||
api_type: React.ReactNode
|
||||
api_description: React.ReactNode
|
||||
api_path: React.ReactNode
|
||||
api_id: React.ReactNode
|
||||
api_name: React.ReactNode
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
status: string
|
||||
endpoints: number
|
||||
lastUpdated: string
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const APIsPage = () => {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [apis, setApis] = useState<API[]>([]);
|
||||
const [allApis, setAllApis] = useState<API[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const router = useRouter()
|
||||
const [apis, setApis] = useState<API[]>([])
|
||||
const [allApis, setAllApis] = useState<API[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState('name')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApis();
|
||||
}, []);
|
||||
fetchApis()
|
||||
}, [])
|
||||
|
||||
const fetchApis = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`http://localhost:3002/platform/api/all`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
@@ -73,28 +45,28 @@ const APIsPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load APIs');
|
||||
throw new Error('Failed to load APIs')
|
||||
}
|
||||
const data = await response.json();
|
||||
const apiList = Array.isArray(data) ? data : (data.apis || data.response?.apis || []);
|
||||
setAllApis(apiList);
|
||||
setApis(apiList);
|
||||
const data = await response.json()
|
||||
const apiList = Array.isArray(data) ? data : (data.apis || data.response?.apis || [])
|
||||
setAllApis(apiList)
|
||||
setApis(apiList)
|
||||
} catch (err) {
|
||||
setError('Failed to load APIs. Please try again later.');
|
||||
setApis([]);
|
||||
setAllApis([]);
|
||||
setError('Failed to load APIs. Please try again later.')
|
||||
setApis([])
|
||||
setAllApis([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!searchTerm.trim()) {
|
||||
setApis(allApis);
|
||||
return;
|
||||
setApis(allApis)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredApis = allApis.filter(api =>
|
||||
@@ -103,124 +75,119 @@ const APIsPage = () => {
|
||||
(api.api_type as string)?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(api.api_path as string)?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(api.api_description as string)?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
setApis(filteredApis);
|
||||
};
|
||||
)
|
||||
setApis(filteredApis)
|
||||
}
|
||||
|
||||
const handleSort = (sortField: string) => {
|
||||
setSortBy(sortField);
|
||||
setSortBy(sortField)
|
||||
const sortedApis = [...apis].sort((a, b) => {
|
||||
if (sortField === 'api_name') {
|
||||
return (a.api_name as string).localeCompare(b.api_name as string);
|
||||
return (a.api_name as string).localeCompare(b.api_name as string)
|
||||
} else if (sortField === 'api_version') {
|
||||
return (a.api_version as string).localeCompare(b.api_version as string);
|
||||
return (a.api_version as string).localeCompare(b.api_version as string)
|
||||
} else if (sortField === 'api_type') {
|
||||
return (a.api_type as string).localeCompare(b.api_type as string);
|
||||
return (a.api_type as string).localeCompare(b.api_type as string)
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setApis(sortedApis);
|
||||
};
|
||||
return 0
|
||||
})
|
||||
setApis(sortedApis)
|
||||
}
|
||||
|
||||
const handleApiClick = (api: API) => {
|
||||
sessionStorage.setItem('selectedApi', JSON.stringify(api));
|
||||
router.push(`/apis/${api.api_id}`);
|
||||
};
|
||||
sessionStorage.setItem('selectedApi', JSON.stringify(api))
|
||||
router.push(`/apis/${api.api_id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="apis-topbar">
|
||||
Doorman
|
||||
<button
|
||||
className="apis-refresh-btn"
|
||||
onClick={fetchApis}
|
||||
aria-label="Refresh APIs"
|
||||
>
|
||||
<span className="refresh-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="apis-root">
|
||||
<aside className="apis-sidebar">
|
||||
<div className="apis-sidebar-title">Menu</div>
|
||||
<ul className="apis-sidebar-list">
|
||||
{menuItems.map((item) => (
|
||||
item.href ? (
|
||||
<li key={`menu-${item.label}`} className={`apis-sidebar-item${item.label === 'APIs' ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={`menu-${item.label}`} className={`apis-sidebar-item${item.label === 'APIs' ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="apis-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="apis-main">
|
||||
<div className="apis-header-row">
|
||||
<h1 className="apis-title">APIs</h1>
|
||||
<button className="refresh-button" onClick={fetchApis}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">APIs</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage and monitor your API endpoints
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/apis/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add API
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="apis-controls">
|
||||
<form className="apis-search-box" onSubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className="apis-search-input"
|
||||
placeholder="Search APIs..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="apis-search-btn">Search</button>
|
||||
<Link href="/apis/add" className="apis-add-btn" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Add API
|
||||
</Link>
|
||||
{/* Search and Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search APIs by name, version, type, or description..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="apis-sort-group">
|
||||
<button
|
||||
key="sort-name"
|
||||
className={`apis-sort-btn ${sortBy === 'api_name' ? 'active' : ''}`}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSort('api_name')}
|
||||
className={`btn ${sortBy === 'api_name' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Name
|
||||
</button>
|
||||
<button
|
||||
key="sort-version"
|
||||
className={`apis-sort-btn ${sortBy === 'api_version' ? 'active' : ''}`}
|
||||
<button
|
||||
onClick={() => handleSort('api_version')}
|
||||
className={`btn ${sortBy === 'api_version' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Version
|
||||
</button>
|
||||
<button
|
||||
key="sort-type"
|
||||
className={`apis-sort-btn ${sortBy === 'api_type' ? 'active' : ''}`}
|
||||
<button
|
||||
onClick={() => handleSort('api_type')}
|
||||
className={`btn ${sortBy === 'api_type' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Status
|
||||
Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading APIs...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading APIs...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="apis-table-panel">
|
||||
<table className="apis-table">
|
||||
</div>
|
||||
) : (
|
||||
/* APIs Table */
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -228,34 +195,91 @@ const APIsPage = () => {
|
||||
<th>Path</th>
|
||||
<th>Description</th>
|
||||
<th>Type</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apis.map((api, index) => (
|
||||
<tr
|
||||
key={api.api_id || `${api.api_name}-${api.api_version}-${index}`}
|
||||
key={String(api.api_id) || `${api.api_name}-${api.api_version}-${index}`}
|
||||
onClick={() => handleApiClick(api)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors"
|
||||
>
|
||||
<td>{api.api_name}</td>
|
||||
<td>
|
||||
<span className="apis-version-badge">{api.api_version}</span>
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary-100 dark:bg-primary-900/20 flex items-center justify-center mr-3">
|
||||
<svg className="h-4 w-4 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{api.api_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-primary">{api.api_version}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code className="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
|
||||
{api.api_path}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{api.api_description}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${
|
||||
(api.api_type as string)?.toLowerCase() === 'rest' ? 'badge-success' :
|
||||
(api.api_type as string)?.toLowerCase() === 'graphql' ? 'badge-warning' :
|
||||
(api.api_type as string)?.toLowerCase() === 'grpc' ? 'badge-error' :
|
||||
'badge-gray'
|
||||
}`}>
|
||||
{api.api_type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td>{api.api_path}</td>
|
||||
<td>{api.api_description}</td>
|
||||
<td>{api.api_type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default APIsPage;
|
||||
{/* Empty State */}
|
||||
{apis.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-16 w-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No APIs found</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{searchTerm ? 'Try adjusting your search terms.' : 'Get started by creating your first API.'}
|
||||
</p>
|
||||
<Link href="/apis/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add API
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default APIsPage
|
||||
|
||||
@@ -1,53 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './dashboard.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface DashboardData {
|
||||
totalRequests: number;
|
||||
activeUsers: number;
|
||||
newApis: number;
|
||||
totalRequests: number
|
||||
activeUsers: number
|
||||
newApis: number
|
||||
monthlyUsage: {
|
||||
[key: string]: number;
|
||||
};
|
||||
[key: string]: number
|
||||
}
|
||||
activeUsersList: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
}>;
|
||||
name: string
|
||||
email: string
|
||||
}>
|
||||
popularApis: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
requests: string;
|
||||
subscribers: number;
|
||||
}>;
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
requests: string
|
||||
subscribers: number
|
||||
}>
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const Dashboard = () => {
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData>({
|
||||
totalRequests: 0,
|
||||
activeUsers: 0,
|
||||
@@ -55,135 +33,185 @@ const Dashboard = () => {
|
||||
monthlyUsage: {},
|
||||
activeUsersList: [],
|
||||
popularApis: []
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/platform/dashboard`);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/dashboard`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch dashboard data');
|
||||
throw new Error('Failed to fetch dashboard data')
|
||||
}
|
||||
const data = await response.json();
|
||||
setDashboardData(data);
|
||||
const data = await response.json()
|
||||
setDashboardData(data)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('An unknown error occurred');
|
||||
setError('An unknown error occurred')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dashboard-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="dashboard-root">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-title">Menu</div>
|
||||
<ul className="sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`sidebar-item${idx === 0 ? ' active' : ''}`}>
|
||||
<a href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</a>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`sidebar-item${idx === 0 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Overview of your API gateway performance and usage
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</aside>
|
||||
<main className="dashboard-main">
|
||||
<div className="dashboard-header">
|
||||
<h1>Dashboard</h1>
|
||||
<button className="refresh-button" onClick={fetchData}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
<span className="error-icon">⚠</span>
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dashboard-cards-row">
|
||||
<div className="dashboard-card">
|
||||
<div className="dashboard-card-title">Total Monthly Requests</div>
|
||||
<div className="dashboard-card-value">{dashboardData.totalRequests.toLocaleString()}</div>
|
||||
<div className="dashboard-card-sub">+17% this month</div>
|
||||
</div>
|
||||
<div className="dashboard-card">
|
||||
<div className="dashboard-card-title">Active Monthly Users</div>
|
||||
<div className="dashboard-card-value">{dashboardData.activeUsers}</div>
|
||||
<div className="dashboard-card-sub">+4% this month</div>
|
||||
</div>
|
||||
<div className="dashboard-card">
|
||||
<div className="dashboard-card-title">New APIs This Month</div>
|
||||
<div className="dashboard-card-value">{dashboardData.newApis}</div>
|
||||
<div className="dashboard-card-sub">+25% this month</div>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Total Monthly Requests</p>
|
||||
<p className="stats-value">{dashboardData.totalRequests.toLocaleString()}</p>
|
||||
<p className="stats-change positive">+17% this month</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-primary-100 dark:bg-primary-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-row">
|
||||
<div className="dashboard-panel dashboard-usage">
|
||||
<div className="dashboard-panel-title">Monthly Usage</div>
|
||||
<div className="dashboard-bar-chart">
|
||||
{['Jan','Feb','Mar','Apr','May','June','July','Aug','Sept','Oct','Nov','Dec'].map((m, i) => (
|
||||
<div key={m} className="bar-group">
|
||||
<div
|
||||
className="bar"
|
||||
style={{
|
||||
height: `${dashboardData.monthlyUsage[m] || 0}%`
|
||||
}}
|
||||
></div>
|
||||
<div className="bar-label">{m}</div>
|
||||
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Active Monthly Users</p>
|
||||
<p className="stats-value">{dashboardData.activeUsers}</p>
|
||||
<p className="stats-change positive">+4% this month</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-success-100 dark:bg-success-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-success-600 dark:text-success-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">New APIs This Month</p>
|
||||
<p className="stats-value">{dashboardData.newApis}</p>
|
||||
<p className="stats-change positive">+25% this month</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-warning-100 dark:bg-warning-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-warning-600 dark:text-warning-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts and Lists */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Monthly Usage Chart */}
|
||||
<div className="lg:col-span-2 card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Monthly Usage</h3>
|
||||
</div>
|
||||
<div className="h-64 flex items-end justify-between gap-2">
|
||||
{months.map((month) => (
|
||||
<div key={month} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-primary-500 to-primary-600 rounded-t-lg transition-all duration-300 hover:from-primary-600 hover:to-primary-700"
|
||||
style={{
|
||||
height: `${Math.max((dashboardData.monthlyUsage[month] || 0) * 2, 4)}px`
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mt-2">{month}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Users */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Most Active Users</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{dashboardData.activeUsersList.map((user, index) => (
|
||||
<div key={user.email} className="flex items-center space-x-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-panel dashboard-users">
|
||||
<div className="dashboard-panel-title">Most Active Users</div>
|
||||
<ul className="dashboard-user-list">
|
||||
{dashboardData.activeUsersList.map(u => (
|
||||
<li key={u.email} className="dashboard-user-item">
|
||||
<div className="user-avatar"></div>
|
||||
<div>
|
||||
<div className="user-name">{u.name}</div>
|
||||
<div className="user-email">{u.email}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-panel dashboard-table">
|
||||
<div className="dashboard-panel-title">Popular APIs This Month</div>
|
||||
<table className="dashboard-table-main">
|
||||
</div>
|
||||
|
||||
{/* Popular APIs Table */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Popular APIs This Month</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Version</th>
|
||||
<th>Requests</th>
|
||||
@@ -192,23 +220,31 @@ const Dashboard = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboardData.popularApis.map(api => (
|
||||
{dashboardData.popularApis.map((api) => (
|
||||
<tr key={api.id}>
|
||||
<td>{api.id}</td>
|
||||
<td>{api.name}</td>
|
||||
<td>{api.version}</td>
|
||||
<td className="font-mono text-sm">{api.id}</td>
|
||||
<td className="font-medium">{api.name}</td>
|
||||
<td>
|
||||
<span className="badge badge-primary">{api.version}</span>
|
||||
</td>
|
||||
<td>{api.requests}</td>
|
||||
<td>{api.subscribers}</td>
|
||||
<td>...</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 dark:bg-dark-bg text-gray-900 dark:text-dark-text;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Button variants */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 shadow-sm;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-dark-surface dark:text-dark-text dark:hover:bg-dark-surfaceHover;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500 dark:text-dark-textSecondary dark:hover:bg-dark-surfaceHover;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-error-600 text-white hover:bg-error-700 focus:ring-error-500;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm placeholder-gray-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-dark-border dark:bg-dark-surface dark:text-dark-text dark:placeholder-gray-400 dark:focus:border-primary-400 dark:focus:ring-primary-400;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply rounded-xl bg-white p-6 shadow-soft dark:bg-dark-surface dark:shadow-none dark:border dark:border-dark-border;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply mb-4 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-dark-text;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.table th {
|
||||
@apply border-b border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:border-dark-border dark:bg-dark-surface dark:text-dark-textSecondary;
|
||||
}
|
||||
|
||||
.table td {
|
||||
@apply border-b border-gray-100 px-4 py-4 text-sm text-gray-900 dark:border-dark-border dark:text-dark-text;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
@apply hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-warning-100 text-warning-800 dark:bg-warning-900 dark:text-warning-200;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply bg-error-100 text-error-800 dark:bg-error-900 dark:text-error-200;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
@apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600 h-6 w-6;
|
||||
}
|
||||
|
||||
/* Sidebar styles */
|
||||
.sidebar {
|
||||
@apply fixed left-0 top-0 z-40 h-screen w-64 -translate-x-full transform bg-white shadow-strong transition-transform duration-300 dark:bg-dark-surface dark:border-r dark:border-dark-border lg:translate-x-0;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
@apply flex items-center rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition-colors duration-200 hover:bg-gray-100 hover:text-gray-900 dark:text-dark-textSecondary dark:hover:bg-dark-surfaceHover dark:hover:text-dark-text;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
@apply bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400;
|
||||
}
|
||||
|
||||
/* Topbar styles */
|
||||
.topbar {
|
||||
@apply fixed top-0 z-30 h-16 w-full bg-white/80 backdrop-blur-sm border-b border-gray-200 dark:bg-dark-surface/80 dark:border-dark-border;
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.main-content {
|
||||
@apply lg:ml-64 pt-16 min-h-screen bg-gray-50 dark:bg-dark-bg;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
@apply mb-6 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-gray-900 dark:text-dark-text;
|
||||
}
|
||||
|
||||
/* Search input */
|
||||
.search-input {
|
||||
@apply w-full max-w-md rounded-lg border border-gray-300 bg-white pl-10 pr-4 py-2 text-sm placeholder-gray-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-dark-border dark:bg-dark-surface dark:text-dark-text dark:placeholder-gray-400;
|
||||
}
|
||||
|
||||
/* Stats cards */
|
||||
.stats-card {
|
||||
@apply rounded-xl bg-white p-6 shadow-soft dark:bg-dark-surface dark:border dark:border-dark-border;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
@apply text-3xl font-bold text-gray-900 dark:text-dark-text;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
@apply text-sm font-medium text-gray-500 dark:text-dark-textSecondary;
|
||||
}
|
||||
|
||||
.stats-change {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
.stats-change.positive {
|
||||
@apply text-success-600 dark:text-success-400;
|
||||
}
|
||||
|
||||
.stats-change.negative {
|
||||
@apply text-error-600 dark:text-error-400;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-primary-600 to-primary-800 bg-clip-text text-transparent;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import '../groups.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Group {
|
||||
group_name: string;
|
||||
group_description: string;
|
||||
api_access?: string[];
|
||||
group_name: string
|
||||
group_description: string
|
||||
api_access?: string[]
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const GroupDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const groupName = params.groupName as string;
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const groupName = params.groupName as string
|
||||
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [group, setGroup] = useState<Group | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<Group>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||||
const [group, setGroup] = useState<Group | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editData, setEditData] = useState<Partial<Group>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('')
|
||||
const [newApi, setNewApi] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroup();
|
||||
}, [groupName]);
|
||||
fetchGroup()
|
||||
}, [groupName])
|
||||
|
||||
const fetchGroup = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Try to get from sessionStorage first
|
||||
const savedGroup = sessionStorage.getItem('selectedGroup');
|
||||
const savedGroup = sessionStorage.getItem('selectedGroup')
|
||||
if (savedGroup) {
|
||||
const parsedGroup = JSON.parse(savedGroup);
|
||||
const parsedGroup = JSON.parse(savedGroup)
|
||||
if (parsedGroup.group_name === groupName) {
|
||||
setGroup(parsedGroup);
|
||||
setEditData(parsedGroup);
|
||||
setLoading(false);
|
||||
return;
|
||||
setGroup(parsedGroup)
|
||||
setEditData(parsedGroup)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,35 +57,41 @@ const GroupDetailPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load group');
|
||||
throw new Error('Failed to load group')
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setGroup(data);
|
||||
setEditData(data);
|
||||
const data = await response.json()
|
||||
setGroup(data)
|
||||
setEditData(data)
|
||||
} catch (err) {
|
||||
setError('Failed to load group. Please try again later.');
|
||||
setError('Failed to load group. Please try again later.')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/groups')
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditData(group || {});
|
||||
};
|
||||
setIsEditing(false)
|
||||
if (group) {
|
||||
setEditData(group)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/group/${encodeURIComponent(groupName)}`, {
|
||||
method: 'PUT',
|
||||
@@ -122,26 +102,38 @@ const GroupDetailPage = () => {
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(editData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to update group');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to update group')
|
||||
}
|
||||
|
||||
setGroup({ ...group, ...editData } as Group);
|
||||
setIsEditing(false);
|
||||
const updatedGroup = await response.json()
|
||||
setGroup(updatedGroup)
|
||||
setIsEditing(false)
|
||||
setSuccess('Group updated successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update group. Please try again later.');
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to update group. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteConfirmation !== group?.group_name) {
|
||||
setError('Group name does not match')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/group/${encodeURIComponent(groupName)}`, {
|
||||
method: 'DELETE',
|
||||
@@ -151,295 +143,331 @@ const GroupDetailPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete group');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to delete group')
|
||||
}
|
||||
|
||||
router.push('/groups');
|
||||
router.push('/groups')
|
||||
} catch (err) {
|
||||
setError('Failed to delete group. Please try again later.');
|
||||
setShowDeleteModal(false);
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to delete group. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleting(false)
|
||||
setShowDeleteModal(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof Group, value: any) => {
|
||||
setEditData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
setEditData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleApiAccessChange = (index: number, value: string) => {
|
||||
const newApiAccess = [...(editData.api_access || [])];
|
||||
newApiAccess[index] = value;
|
||||
setEditData(prev => ({ ...prev, api_access: newApiAccess }));
|
||||
};
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
api_access: prev.api_access?.map((api, i) => i === index ? value : api) || []
|
||||
}))
|
||||
}
|
||||
|
||||
const addApiAccess = () => {
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
api_access: [...(prev.api_access || []), '']
|
||||
}));
|
||||
};
|
||||
if (newApi.trim() && !editData.api_access?.includes(newApi.trim())) {
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
api_access: [...(prev.api_access || []), newApi.trim()]
|
||||
}))
|
||||
setNewApi('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeApiAccess = (index: number) => {
|
||||
const newApiAccess = [...(editData.api_access || [])];
|
||||
newApiAccess.splice(index, 1);
|
||||
setEditData(prev => ({ ...prev, api_access: newApiAccess }));
|
||||
};
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
api_access: prev.api_access?.filter((_, i) => i !== index) || []
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<div className="groups-topbar">Doorman</div>
|
||||
<div className="groups-root">
|
||||
<aside className="groups-sidebar">
|
||||
<div className="groups-sidebar-title">Menu</div>
|
||||
<ul className="groups-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="groups-logout-btn" onClick={handleLogout}>Logout</button>
|
||||
</aside>
|
||||
<main className="groups-main">
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading group...</p>
|
||||
</div>
|
||||
</main>
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading group details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
if (error && !group) {
|
||||
return (
|
||||
<>
|
||||
<div className="groups-topbar">Doorman</div>
|
||||
<div className="groups-root">
|
||||
<aside className="groups-sidebar">
|
||||
<div className="groups-sidebar-title">Menu</div>
|
||||
<ul className="groups-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="groups-logout-btn" onClick={handleLogout}>Logout</button>
|
||||
</aside>
|
||||
<main className="groups-main">
|
||||
<div className="error-container">
|
||||
<div className="error-message">Group not found</div>
|
||||
<Link href="/groups" className="back-link">Back to Groups</Link>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Group Details</h1>
|
||||
</div>
|
||||
</main>
|
||||
<button onClick={handleBack} className="btn btn-secondary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Groups
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="groups-topbar">Doorman</div>
|
||||
<div className="groups-root">
|
||||
<aside className="groups-sidebar">
|
||||
<div className="groups-sidebar-title">Menu</div>
|
||||
<ul className="groups-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="groups-logout-btn" onClick={handleLogout}>Logout</button>
|
||||
</aside>
|
||||
<main className="groups-main">
|
||||
<div className="groups-header">
|
||||
<button className="back-button" onClick={() => router.push('/groups')}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Groups
|
||||
</button>
|
||||
<h1 className="groups-title">Group Details</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{group?.group_name || 'Group Details'}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage group configuration and API access
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isEditing ? (
|
||||
<button className="edit-button" onClick={handleEdit}>
|
||||
Edit Group
|
||||
</button>
|
||||
) : (
|
||||
<div className="edit-actions">
|
||||
<button className="save-button" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
<>
|
||||
<button onClick={handleEdit} className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit Group
|
||||
</button>
|
||||
<button className="cancel-button" onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="delete-button" onClick={() => setShowDeleteModal(true)} disabled={saving}>
|
||||
<button onClick={() => setShowDeleteModal(true)} className="btn btn-error">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete Group
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
|
||||
{saving ? (
|
||||
<div className="flex items-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Saving...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={handleCancel} className="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleBack} className="btn btn-ghost">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">{error}</div>
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="rounded-lg bg-success-50 border border-success-200 p-4 dark:bg-success-900/20 dark:border-success-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-success-400 dark:text-success-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-success-700 dark:text-success-300">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="groups-detail-content">
|
||||
<div className="groups-detail-card">
|
||||
<div className="groups-detail-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label className="info-label">Group Name</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="edit-input"
|
||||
value={editData.group_name || ''}
|
||||
onChange={(e) => handleInputChange('group_name', e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
) : (
|
||||
<span className="info-value">{group.group_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label className="info-label">Description</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="edit-input"
|
||||
value={editData.group_description || ''}
|
||||
onChange={(e) => handleInputChange('group_description', e.target.value)}
|
||||
disabled={saving}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<span className="info-value">{group.group_description || 'No description'}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Group Name
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.group_name || ''}
|
||||
onChange={(e) => handleInputChange('group_name', e.target.value)}
|
||||
className="input"
|
||||
placeholder="Enter group name"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-900 dark:text-white">{group.group_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editData.group_description || ''}
|
||||
onChange={(e) => handleInputChange('group_description', e.target.value)}
|
||||
className="input resize-none"
|
||||
rows={3}
|
||||
placeholder="Enter group description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-600 dark:text-gray-400">{group.group_description || 'No description'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="groups-detail-section">
|
||||
<h2 className="section-title">API Access</h2>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="groups-edit">
|
||||
{(editData.api_access || []).map((api, index) => (
|
||||
<div key={index} className="group-edit-item">
|
||||
<input
|
||||
type="text"
|
||||
className="edit-input"
|
||||
value={api}
|
||||
onChange={(e) => handleApiAccessChange(index, e.target.value)}
|
||||
placeholder="Enter API name/version (e.g., customer/v1)"
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="remove-button"
|
||||
onClick={() => removeApiAccess(index)}
|
||||
disabled={saving}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="add-button"
|
||||
onClick={addApiAccess}
|
||||
disabled={saving}
|
||||
>
|
||||
+ Add API Access
|
||||
{/* API Access */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">API Access</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newApi}
|
||||
onChange={(e) => setNewApi(e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder="Enter API name to grant access"
|
||||
onKeyPress={(e) => e.key === 'Enter' && addApiAccess()}
|
||||
/>
|
||||
<button onClick={addApiAccess} className="btn btn-primary">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="groups-container">
|
||||
{group.api_access && group.api_access.length > 0 ? (
|
||||
<div className="groups-list">
|
||||
{group.api_access.map((api, index) => (
|
||||
<span key={index} className="group-tag">
|
||||
{api}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="no-groups">No API access configured</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{(isEditing ? editData.api_access : group.api_access)?.map((api, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={api}
|
||||
onChange={(e) => handleApiAccessChange(index, e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder="Enter API name"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 px-3 py-1 rounded-full flex-1">
|
||||
{api}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => removeApiAccess(index)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-200"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(!isEditing ? group.api_access : editData.api_access)?.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No APIs assigned</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Delete Group</h2>
|
||||
<button className="modal-close" onClick={() => setShowDeleteModal(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p className="modal-message">
|
||||
This action cannot be undone. This will permanently delete the group <strong>{group?.group_name}</strong>.
|
||||
{/* Delete Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={() => setShowDeleteModal(false)}></div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Delete Group</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
This action cannot be undone. This will permanently delete the group "{group?.group_name}".
|
||||
</p>
|
||||
<p className="modal-warning">
|
||||
To confirm deletion, please type the group name <strong>{group?.group_name}</strong> in the field below:
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Please type <strong>{group?.group_name}</strong> to confirm.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
className="modal-input"
|
||||
placeholder="Enter group name to confirm"
|
||||
value={deleteConfirmation}
|
||||
onChange={(e) => setDeleteConfirmation(e.target.value)}
|
||||
autoFocus
|
||||
className="input w-full mb-4"
|
||||
placeholder="Enter group name to confirm"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="modal-cancel-button"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="modal-delete-button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || deleteConfirmation !== group?.group_name}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Group'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteConfirmation !== group?.group_name || deleting}
|
||||
className="btn btn-error flex-1"
|
||||
>
|
||||
{deleting ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Deleting...
|
||||
</div>
|
||||
) : (
|
||||
'Delete Group'
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => setShowDeleteModal(false)} className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupDetailPage;
|
||||
export default GroupDetailPage
|
||||
@@ -1,87 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import '../groups.css';
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface CreateGroupData {
|
||||
group_name: string;
|
||||
group_description: string;
|
||||
api_access: string[];
|
||||
group_name: string
|
||||
group_description: string
|
||||
api_access: string[]
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const AddGroupPage = () => {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<CreateGroupData>({
|
||||
group_name: '',
|
||||
group_description: '',
|
||||
api_access: []
|
||||
});
|
||||
const [newApi, setNewApi] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
})
|
||||
const [newApi, setNewApi] = useState('')
|
||||
|
||||
const handleInputChange = (field: keyof CreateGroupData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const addApi = () => {
|
||||
if (newApi.trim() && !formData.api_access.includes(newApi.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
api_access: [...prev.api_access, newApi.trim()]
|
||||
}));
|
||||
setNewApi('');
|
||||
}))
|
||||
setNewApi('')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const removeApi = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
api_access: prev.api_access.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.group_name.trim()) {
|
||||
setError('Group name is required');
|
||||
return;
|
||||
setError('Group name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('http://localhost:3002/platform/group', {
|
||||
method: 'POST',
|
||||
@@ -92,149 +64,164 @@ const AddGroupPage = () => {
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to create group');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to create group')
|
||||
}
|
||||
|
||||
// Redirect back to groups list after successful creation
|
||||
router.push('/groups');
|
||||
router.push('/groups')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create group');
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to create group. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="groups-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="groups-root">
|
||||
<aside className="groups-sidebar">
|
||||
<div className="groups-sidebar-title">Menu</div>
|
||||
<ul className="groups-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="groups-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="groups-main">
|
||||
<div className="groups-header-row">
|
||||
<Link href="/groups" className="back-button" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Groups
|
||||
</Link>
|
||||
<h1 className="groups-title">Add Group</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Add Group</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Create a new user group with API access permissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-group-form">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Group Name *</label>
|
||||
{/* Form */}
|
||||
<div className="card max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="group_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Group Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="group_name"
|
||||
name="group_name"
|
||||
className="input"
|
||||
placeholder="Enter group name"
|
||||
value={formData.group_name}
|
||||
onChange={(e) => handleInputChange('group_name', e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="group_description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="group_description"
|
||||
name="group_description"
|
||||
rows={4}
|
||||
className="input resize-none"
|
||||
placeholder="Describe the purpose of this group..."
|
||||
value={formData.group_description}
|
||||
onChange={(e) => handleInputChange('group_description', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Optional description of the group's purpose
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Access
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.group_name}
|
||||
onChange={(e) => handleInputChange('group_name', e.target.value)}
|
||||
placeholder="Enter group name"
|
||||
maxLength={50}
|
||||
required
|
||||
className="input flex-1"
|
||||
placeholder="Enter API name to grant access"
|
||||
value={newApi}
|
||||
onChange={(e) => setNewApi(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addApi())}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addApi}
|
||||
disabled={loading || !newApi.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.group_description}
|
||||
onChange={(e) => handleInputChange('group_description', e.target.value)}
|
||||
placeholder="Enter group description"
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">API Access</h2>
|
||||
<div className="form-group">
|
||||
<label className="form-label">APIs</label>
|
||||
<div className="api-access-container">
|
||||
<div className="api-access-list">
|
||||
{formData.api_access.map((api, index) => (
|
||||
<span key={index} className="api-access-tag">
|
||||
{api}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-api-btn"
|
||||
onClick={() => removeApi(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-api">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter API name"
|
||||
value={newApi}
|
||||
onChange={(e) => setNewApi(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addApi())}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.api_access.map((api, index) => (
|
||||
<div key={index} className="flex items-center gap-2 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 px-3 py-1 rounded-full">
|
||||
<span className="text-sm">{api}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="add-button"
|
||||
onClick={addApi}
|
||||
onClick={() => removeApi(index)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
disabled={loading}
|
||||
>
|
||||
Add API
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{formData.api_access.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No APIs assigned yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<Link href="/groups" className="cancel-button" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className="save-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<div className="flex gap-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Creating Group...
|
||||
</div>
|
||||
) : (
|
||||
'Create Group'
|
||||
)}
|
||||
</button>
|
||||
<Link href="/groups" className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddGroupPage;
|
||||
export default AddGroupPage
|
||||
+164
-146
@@ -1,61 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import './groups.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Group {
|
||||
group_name: string;
|
||||
group_description: string;
|
||||
api_access?: string[];
|
||||
group_name: string
|
||||
group_description: string
|
||||
api_access?: string[]
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const GroupsPage = () => {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [allGroups, setAllGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('group_name');
|
||||
const router = useRouter()
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [allGroups, setAllGroups] = useState<Group[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState('group_name')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, []);
|
||||
fetchGroups()
|
||||
}, [])
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`http://localhost:3002/platform/group/all?page=1&page_size=10`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
@@ -63,167 +35,213 @@ const GroupsPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load groups');
|
||||
throw new Error('Failed to load groups')
|
||||
}
|
||||
const data = await response.json();
|
||||
setAllGroups(data.groups);
|
||||
setGroups(data.groups);
|
||||
const data = await response.json()
|
||||
setAllGroups(data.groups)
|
||||
setGroups(data.groups)
|
||||
} catch (err) {
|
||||
setError('Failed to load groups. Please try again later.');
|
||||
setGroups([]);
|
||||
setAllGroups([]);
|
||||
setError('Failed to load groups. Please try again later.')
|
||||
setGroups([])
|
||||
setAllGroups([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!searchTerm.trim()) {
|
||||
setGroups(allGroups);
|
||||
return;
|
||||
setGroups(allGroups)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredGroups = allGroups.filter(group =>
|
||||
group.group_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
group.group_description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
group.api_access?.some(api => api.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
setGroups(filteredGroups);
|
||||
};
|
||||
)
|
||||
setGroups(filteredGroups)
|
||||
}
|
||||
|
||||
const handleSort = (sortField: string) => {
|
||||
setSortBy(sortField);
|
||||
setSortBy(sortField)
|
||||
const sortedGroups = [...groups].sort((a, b) => {
|
||||
if (sortField === 'group_name') {
|
||||
return a.group_name.localeCompare(b.group_name);
|
||||
return a.group_name.localeCompare(b.group_name)
|
||||
} else if (sortField === 'api_access') {
|
||||
return (a.api_access?.length || 0) - (b.api_access?.length || 0);
|
||||
return (a.api_access?.length || 0) - (b.api_access?.length || 0)
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setGroups(sortedGroups);
|
||||
};
|
||||
return 0
|
||||
})
|
||||
setGroups(sortedGroups)
|
||||
}
|
||||
|
||||
const handleGroupClick = (group: Group) => {
|
||||
// Store group data in sessionStorage for the detail page
|
||||
sessionStorage.setItem('selectedGroup', JSON.stringify(group));
|
||||
router.push(`/groups/${group.group_name}`);
|
||||
};
|
||||
sessionStorage.setItem('selectedGroup', JSON.stringify(group))
|
||||
router.push(`/groups/${group.group_name}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="groups-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="groups-root">
|
||||
<aside className="groups-sidebar">
|
||||
<div className="groups-sidebar-title">Menu</div>
|
||||
<ul className="groups-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`groups-sidebar-item${idx === 4 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="groups-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="groups-main">
|
||||
<div className="groups-header-row">
|
||||
<h1 className="groups-title">Groups</h1>
|
||||
<button className="refresh-button" onClick={fetchGroups}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Groups</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage user groups and API access permissions
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/groups/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Group
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="groups-controls">
|
||||
<form className="groups-search-box" onSubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className="groups-search-input"
|
||||
placeholder="Search groups..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="groups-search-btn">Search</button>
|
||||
<Link href="/groups/add" className="groups-add-btn" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Add Group
|
||||
</Link>
|
||||
{/* Search and Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search groups by name, description, or API access..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="groups-sort-group">
|
||||
<button
|
||||
className={`groups-sort-btn ${sortBy === 'group_name' ? 'active' : ''}`}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSort('group_name')}
|
||||
className={`btn ${sortBy === 'group_name' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Name
|
||||
</button>
|
||||
<button
|
||||
className={`groups-sort-btn ${sortBy === 'api_access' ? 'active' : ''}`}
|
||||
<button
|
||||
onClick={() => handleSort('api_access')}
|
||||
className={`btn ${sortBy === 'api_access' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
API Access
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading groups...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading groups...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="groups-table-panel">
|
||||
<table className="groups-table">
|
||||
</div>
|
||||
) : (
|
||||
/* Groups Table */
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>API Access</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group) => (
|
||||
<tr
|
||||
key={group.group_name}
|
||||
key={group.group_name}
|
||||
onClick={() => handleGroupClick(group)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors"
|
||||
>
|
||||
<td><b>{group.group_name}</b></td>
|
||||
<td>{group.group_description || 'No description'}</td>
|
||||
<td>
|
||||
<span className="groups-api-badge">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-green-400 to-green-600 flex items-center justify-center mr-3">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{group.group_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{group.group_description || 'No description'}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-success">
|
||||
{group.api_access?.length || 0} API{(group.api_access?.length || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
||||
{/* Empty State */}
|
||||
{groups.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-16 w-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No groups found</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{searchTerm ? 'Try adjusting your search terms.' : 'Get started by creating your first user group.'}
|
||||
</p>
|
||||
<Link href="/groups/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Group
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupsPage
|
||||
@@ -1,11 +1,29 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Doorman - API Gateway Management',
|
||||
description: 'Modern API gateway management platform',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<html lang="en" className="h-full">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body className={`${inter.className} h-full bg-gray-50 dark:bg-dark-bg text-gray-900 dark:text-dark-text antialiased transition-colors duration-200`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
+342
-331
@@ -1,77 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ChangeEvent } from 'react';
|
||||
import './logging.css';
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { ChangeEvent } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Log {
|
||||
timestamp: string;
|
||||
request_id?: string;
|
||||
level: string;
|
||||
message: string;
|
||||
source: string;
|
||||
user?: string;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
ipAddress?: string;
|
||||
responseTime?: number;
|
||||
timestamp: string
|
||||
request_id?: string
|
||||
level: string
|
||||
message: string
|
||||
source: string
|
||||
user?: string
|
||||
endpoint?: string
|
||||
method?: string
|
||||
ipAddress?: string
|
||||
responseTime?: number
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
user: string;
|
||||
endpoint: string;
|
||||
request_id: string;
|
||||
method: string;
|
||||
ipAddress: string;
|
||||
minResponseTime: string;
|
||||
maxResponseTime: string;
|
||||
level: string;
|
||||
startDate: string
|
||||
endDate: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
user: string
|
||||
endpoint: string
|
||||
request_id: string
|
||||
method: string
|
||||
ipAddress: string
|
||||
minResponseTime: string
|
||||
maxResponseTime: string
|
||||
level: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function LogsPage() {
|
||||
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showMoreFilters, setShowMoreFilters] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [logs, setLogs] = useState<Log[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showMoreFilters, setShowMoreFilters] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [filters, setFilters] = useState<FilterState>(() => {
|
||||
// Set default to last 30 minutes of today
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0]; // Always use today's date
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
|
||||
// Calculate 30 minutes ago, but ensure we stay within today
|
||||
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000);
|
||||
const startTime = thirtyMinutesAgo.toTimeString().slice(0, 5);
|
||||
const endTime = now.toTimeString().slice(0, 5);
|
||||
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000)
|
||||
const startTime = thirtyMinutesAgo.toTimeString().slice(0, 5)
|
||||
const endTime = now.toTimeString().slice(0, 5)
|
||||
|
||||
return {
|
||||
startDate: today,
|
||||
@@ -86,87 +60,57 @@ export default function LogsPage() {
|
||||
minResponseTime: '',
|
||||
maxResponseTime: '',
|
||||
level: ''
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [filters]);
|
||||
fetchLogs()
|
||||
}, [filters])
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filters.startDate) queryParams.append('start_date', filters.startDate);
|
||||
if (filters.endDate) queryParams.append('end_date', filters.endDate);
|
||||
if (filters.startTime) queryParams.append('start_time', filters.startTime);
|
||||
if (filters.endTime) queryParams.append('end_time', filters.endTime);
|
||||
if (filters.user) queryParams.append('user', filters.user);
|
||||
if (filters.endpoint) queryParams.append('endpoint', filters.endpoint);
|
||||
if (filters.request_id) queryParams.append('request_id', filters.request_id);
|
||||
if (filters.method) queryParams.append('method', filters.method);
|
||||
if (filters.ipAddress) queryParams.append('ip_address', filters.ipAddress);
|
||||
if (filters.minResponseTime) queryParams.append('min_response_time', filters.minResponseTime);
|
||||
if (filters.maxResponseTime) queryParams.append('max_response_time', filters.maxResponseTime);
|
||||
if (filters.level) queryParams.append('level', filters.level);
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002';
|
||||
const url = `${serverUrl}/platform/logging/logs?${queryParams.toString()}`;
|
||||
console.log('Fetching logs with URL:', url);
|
||||
console.log('Filters:', filters);
|
||||
const queryParams = new URLSearchParams()
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) queryParams.append(key, value)
|
||||
})
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/logging?${queryParams}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const errorMessage = errorData.error_message || `HTTP ${response.status}: ${response.statusText}`;
|
||||
throw new Error(errorMessage);
|
||||
throw new Error('Failed to fetch logs')
|
||||
}
|
||||
const responseData = await response.json();
|
||||
|
||||
// Handle the response format
|
||||
if (responseData && responseData.logs) {
|
||||
setLogs(responseData.logs);
|
||||
} else if (responseData && Array.isArray(responseData)) {
|
||||
// Fallback for array format
|
||||
setLogs(responseData);
|
||||
} else {
|
||||
// No logs found or unexpected format
|
||||
setLogs([]);
|
||||
console.warn('Unexpected response format:', responseData);
|
||||
}
|
||||
const data = await response.json()
|
||||
setLogs(data.logs || [])
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('An unknown error occurred');
|
||||
}
|
||||
setError('Failed to fetch logs. Please try again later.')
|
||||
setLogs([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleFilterChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
const { name, value } = e.target
|
||||
setFilters(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0]; // Always use today's date
|
||||
|
||||
// Calculate 30 minutes ago, but ensure we stay within today
|
||||
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000);
|
||||
const startTime = thirtyMinutesAgo.toTimeString().slice(0, 5);
|
||||
const endTime = now.toTimeString().slice(0, 5);
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000)
|
||||
const startTime = thirtyMinutesAgo.toTimeString().slice(0, 5)
|
||||
const endTime = now.toTimeString().slice(0, 5)
|
||||
|
||||
setFilters({
|
||||
startDate: today,
|
||||
@@ -181,200 +125,215 @@ export default function LogsPage() {
|
||||
minResponseTime: '',
|
||||
maxResponseTime: '',
|
||||
level: ''
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const exportLogs = async (format: 'json' | 'csv') => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('format', format);
|
||||
if (filters.startDate) queryParams.append('start_date', filters.startDate);
|
||||
if (filters.endDate) queryParams.append('end_date', filters.endDate);
|
||||
if (filters.user) queryParams.append('user', filters.user);
|
||||
if (filters.endpoint) queryParams.append('endpoint', filters.endpoint);
|
||||
if (filters.level) queryParams.append('level', filters.level);
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002';
|
||||
const response = await fetch(`${serverUrl}/platform/logging/logs/download?${queryParams.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
setExporting(true)
|
||||
const queryParams = new URLSearchParams()
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) queryParams.append(key, value)
|
||||
})
|
||||
queryParams.append('format', format)
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/logging/export?${queryParams}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to export logs');
|
||||
throw new Error('Failed to export logs')
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `logs_export_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `logs-${new Date().toISOString().split('T')[0]}.${format}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('An unknown error occurred during export');
|
||||
}
|
||||
setError('Failed to export logs. Please try again later.')
|
||||
} finally {
|
||||
setExporting(false);
|
||||
setExporting(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error': return 'text-red-600 dark:text-red-400'
|
||||
case 'warn': return 'text-yellow-600 dark:text-yellow-400'
|
||||
case 'info': return 'text-blue-600 dark:text-blue-400'
|
||||
case 'debug': return 'text-gray-600 dark:text-gray-400'
|
||||
default: return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
const getLevelBgColor = (level: string) => {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error': return 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
case 'warn': return 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200'
|
||||
case 'info': return 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
|
||||
case 'debug': return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
default: return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="logs-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="logs-root">
|
||||
<aside className="logs-sidebar">
|
||||
<div className="logs-sidebar-title">Menu</div>
|
||||
<ul className="logs-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`logs-sidebar-item${idx === 7 ? ' active' : ''}`}>
|
||||
<a href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</a>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`logs-sidebar-item${idx === 7 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="logs-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="logs-main">
|
||||
<div className="logs-header">
|
||||
<h1>Logs</h1>
|
||||
<div className="logs-header-controls">
|
||||
<div className="export-buttons">
|
||||
<button
|
||||
className="export-button"
|
||||
onClick={() => exportLogs('json')}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Export JSON'}
|
||||
</button>
|
||||
<button
|
||||
className="export-button"
|
||||
onClick={() => exportLogs('csv')}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? 'Exporting...' : 'Export CSV'}
|
||||
</button>
|
||||
</div>
|
||||
<button className="refresh-button" onClick={fetchLogs}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Logs</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
View and analyze system logs and API requests
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => exportLogs('json')}
|
||||
disabled={exporting}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportLogs('csv')}
|
||||
disabled={exporting}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters-container">
|
||||
<div className="filters-grid">
|
||||
<div className="filter-group">
|
||||
<label htmlFor="startDate">Start Date</label>
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Filters</h3>
|
||||
<button
|
||||
onClick={() => setShowMoreFilters(!showMoreFilters)}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
{showMoreFilters ? 'Show Less' : 'Show More'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
value={filters.startDate}
|
||||
onChange={handleFilterChange}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="endDate">End Date</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
value={filters.endDate}
|
||||
onChange={handleFilterChange}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="startTime">Start Time</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Start Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="startTime"
|
||||
name="startTime"
|
||||
value={filters.startTime}
|
||||
onChange={handleFilterChange}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="endTime">End Time</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
End Time
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
value={filters.endTime}
|
||||
onChange={handleFilterChange}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-buttons">
|
||||
<button
|
||||
className="more-filters-button"
|
||||
onClick={() => setShowMoreFilters(!showMoreFilters)}
|
||||
>
|
||||
{showMoreFilters ? 'Show Less Filters' : 'More Filters'}
|
||||
<span className={`more-filters-icon ${showMoreFilters ? 'expanded' : ''}`}>▼</span>
|
||||
</button>
|
||||
<button
|
||||
className="clear-filters-button"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMoreFilters && (
|
||||
<div className="more-filters-section">
|
||||
<div className="filter-group">
|
||||
<label htmlFor="user">User</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
User
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user"
|
||||
name="user"
|
||||
placeholder="Filter by user"
|
||||
value={filters.user}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Filter by user"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="endpoint">Endpoint</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Endpoint
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="endpoint"
|
||||
name="endpoint"
|
||||
placeholder="Filter by endpoint"
|
||||
value={filters.endpoint}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Filter by endpoint"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="request_id">Request ID</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Request ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="request_id"
|
||||
name="request_id"
|
||||
placeholder="Filter by request ID"
|
||||
value={filters.request_id}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Filter by request ID"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="method">HTTP Method</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Method
|
||||
</label>
|
||||
<select
|
||||
id="method"
|
||||
name="method"
|
||||
value={filters.method}
|
||||
onChange={handleFilterChange}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Methods</option>
|
||||
<option value="GET">GET</option>
|
||||
@@ -382,127 +341,179 @@ export default function LogsPage() {
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="HEAD">HEAD</option>
|
||||
<option value="OPTIONS">OPTIONS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="ipAddress">IP Address</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
IP Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ipAddress"
|
||||
name="ipAddress"
|
||||
placeholder="Filter by IP"
|
||||
value={filters.ipAddress}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Filter by IP"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="minResponseTime">Min Response Time (ms)</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Min Response Time (ms)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="minResponseTime"
|
||||
name="minResponseTime"
|
||||
placeholder="Min time"
|
||||
value={filters.minResponseTime}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Min time"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="maxResponseTime">Max Response Time (ms)</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Max Response Time (ms)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxResponseTime"
|
||||
name="maxResponseTime"
|
||||
placeholder="Max time"
|
||||
value={filters.maxResponseTime}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Max time"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<label htmlFor="level">Log Level</label>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Log Level
|
||||
</label>
|
||||
<select
|
||||
id="level"
|
||||
name="level"
|
||||
value={filters.level}
|
||||
onChange={handleFilterChange}
|
||||
className="input"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="WARNING">Warning</option>
|
||||
<option value="WARN">Warning</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-6">
|
||||
<button onClick={fetchLogs} className="btn btn-primary">
|
||||
Apply Filters
|
||||
</button>
|
||||
<button onClick={clearFilters} className="btn btn-secondary">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading logs...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading logs...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="logs-container">
|
||||
{logs.length === 0 ? (
|
||||
<div className="no-logs">
|
||||
No logs found for the selected time period
|
||||
</div>
|
||||
) : (
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Request ID</th>
|
||||
<th>Level</th>
|
||||
<th>Message</th>
|
||||
<th>Source</th>
|
||||
<th>User</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Method</th>
|
||||
<th>Response Time</th>
|
||||
<th>IP Address</th>
|
||||
</div>
|
||||
) : (
|
||||
/* Logs Table */
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Level</th>
|
||||
<th>Message</th>
|
||||
<th>User</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Method</th>
|
||||
<th>Response Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors">
|
||||
<td>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm:ss')}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${getLevelBgColor(log.level)}`}>
|
||||
{log.level}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{log.message}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{log.user || '-'}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{log.endpoint || '-'}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${log.method === 'GET' ? 'badge-success' : log.method === 'POST' ? 'badge-primary' : 'badge-warning'}`}>
|
||||
{log.method || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{log.responseTime ? `${log.responseTime}ms` : '-'}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log, index) => (
|
||||
<tr key={index} className={`log-row ${log.level.toLowerCase()}`}>
|
||||
<td>{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}</td>
|
||||
<td>{log.request_id || '-'}</td>
|
||||
<td>
|
||||
<span className={`log-level ${log.level.toLowerCase()}`}>
|
||||
{log.level}
|
||||
</span>
|
||||
</td>
|
||||
<td>{log.message}</td>
|
||||
<td>{log.source}</td>
|
||||
<td>{log.user || '-'}</td>
|
||||
<td>{log.endpoint || '-'}</td>
|
||||
<td>{log.method || '-'}</td>
|
||||
<td>
|
||||
<span className={`status-code ${log.responseTime ? (log.responseTime < 100 ? 'success' : log.responseTime < 500 ? 'warning' : 'error') : ''}`}>
|
||||
{log.responseTime ? `${log.responseTime}ms` : '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{log.ipAddress || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Empty State */}
|
||||
{logs.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-16 w-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No logs found</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Try adjusting your filters or check back later for new logs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import "./login.css";
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const LoginPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [theme, setTheme] = useState('light')
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
const savedTheme = localStorage.getItem('theme') || 'light'
|
||||
setTheme(savedTheme)
|
||||
document.documentElement.classList.toggle('dark', savedTheme === 'dark')
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const authResponse = await fetch(`http://localhost:3002/platform/authorization`, {
|
||||
@@ -29,80 +31,164 @@ const LoginPage = () => {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
})
|
||||
|
||||
if (authResponse.ok) {
|
||||
const data = await authResponse.json();
|
||||
const data = await authResponse.json()
|
||||
if (data.access_token) {
|
||||
// Set the cookie with the access token
|
||||
document.cookie = `access_token_cookie=${data.access_token}; path=/; domain=localhost`;
|
||||
console.log('Access token cookie set:', document.cookie);
|
||||
document.cookie = `access_token_cookie=${data.access_token}; path=/; domain=localhost`
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
console.error('No access token in response:', data);
|
||||
setErrorMessage('Login successful but no access token received');
|
||||
return;
|
||||
setErrorMessage('Login successful but no access token received')
|
||||
}
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
const errorData = await authResponse.json();
|
||||
setErrorMessage(errorData.detail || 'An error occurred');
|
||||
const errorData = await authResponse.json()
|
||||
setErrorMessage(errorData.detail || 'Invalid email or password')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
setErrorMessage('Invalid email or password');
|
||||
console.error('Login error:', error)
|
||||
setErrorMessage('Network error. Please try again.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light'
|
||||
setTheme(newTheme)
|
||||
localStorage.setItem('theme', newTheme)
|
||||
document.documentElement.classList.toggle('dark', newTheme === 'dark')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="container">
|
||||
<div className="doorman-logo">
|
||||
Doorman
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-blue-50 to-indigo-100 dark:from-dark-bg dark:via-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 h-80 w-80 rounded-full bg-gradient-to-br from-primary-400/20 to-primary-600/20 blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-gradient-to-tr from-purple-400/20 to-purple-600/20 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="absolute top-6 right-6 rounded-lg p-2 text-gray-500 hover:bg-white/50 dark:text-gray-400 dark:hover:bg-dark-surface/50 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Logo and title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto mb-4 h-16 w-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center shadow-lg">
|
||||
<span className="text-white font-bold text-2xl">D</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Sign in to your Doorman account
|
||||
</p>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="copy">
|
||||
<h1 className="title">Login</h1>
|
||||
<p className="enter-your-email-and-password">
|
||||
Enter your email and password
|
||||
|
||||
{/* Login form */}
|
||||
<div className="card animate-fade-in">
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
className="input"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
className="input"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn btn-primary w-full py-3 text-base font-medium"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
By signing in, you agree to our{' '}
|
||||
<a href="/terms" className="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 font-medium">
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="/privacy" className="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 font-medium">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="input-and-button">
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="email-field">
|
||||
<input
|
||||
type="email"
|
||||
className="email"
|
||||
placeholder="email@domain.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="password-field">
|
||||
<input
|
||||
type="password"
|
||||
className="password"
|
||||
placeholder="••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="button" onClick={handleLogin}>Login</button>
|
||||
</form>
|
||||
</div>
|
||||
{errorMessage && <div className="error-message">{errorMessage}</div>}
|
||||
<p className="terms">
|
||||
By clicking Login, you agree to our{" "}
|
||||
<a href="/terms" className="link">Terms of Service</a> and{" "}
|
||||
<a href="/privacy" className="link">Privacy Policy</a>.
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Need help?{' '}
|
||||
<a href="/support" className="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 font-medium">
|
||||
Contact support
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
export default LoginPage
|
||||
+189
-210
@@ -1,260 +1,239 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import './monitor.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Metric {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
timestamp: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface Metrics {
|
||||
totalRequests: Metric[];
|
||||
errorRate: Metric[];
|
||||
avgResponseTime: Metric[];
|
||||
activeUsers: Metric[];
|
||||
bandwidthUsage: Metric[];
|
||||
cpuUsage: Metric[];
|
||||
memoryUsage: Metric[];
|
||||
totalRequests: Metric[]
|
||||
errorRate: Metric[]
|
||||
avgResponseTime: Metric[]
|
||||
activeUsers: Metric[]
|
||||
bandwidthUsage: Metric[]
|
||||
cpuUsage: Metric[]
|
||||
memoryUsage: Metric[]
|
||||
statusCodes: {
|
||||
[key: string]: number;
|
||||
};
|
||||
[key: string]: number
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const MonitorPage: React.FC = () => {
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
const [timeRange, setTimeRange] = useState('24h');
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [metrics, setMetrics] = useState<any[]>([])
|
||||
const [timeRange, setTimeRange] = useState('24h')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
}, [timeRange]);
|
||||
fetchMetrics()
|
||||
}, [timeRange])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`/api/platform/metrics?range=${timeRange}`);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`/api/platform/metrics?range=${timeRange}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch metrics');
|
||||
throw new Error('Failed to fetch metrics')
|
||||
}
|
||||
const data = await response.json();
|
||||
setMetrics(data);
|
||||
const data = await response.json()
|
||||
setMetrics(data)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('An unknown error occurred');
|
||||
setError('An unknown error occurred')
|
||||
}
|
||||
setMetrics([]);
|
||||
setMetrics([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
}
|
||||
|
||||
const renderMetricChart = (data: Metric[], title: string): React.ReactNode => {
|
||||
return (
|
||||
<div className="monitor-metric-chart">
|
||||
<div className="monitor-chart-placeholder">
|
||||
{title} chart will be implemented here
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{title}</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="h-48 bg-gray-50 dark:bg-gray-800 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">{title} chart will be implemented here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="monitor-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="monitor-root">
|
||||
<aside className="monitor-sidebar">
|
||||
<div className="monitor-sidebar-title">Menu</div>
|
||||
<ul className="monitor-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`monitor-sidebar-item${idx === 6 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`monitor-sidebar-item${idx === 6 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="monitor-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="monitor-main">
|
||||
<div className="monitor-header-row">
|
||||
<h1 className="monitor-title">Monitor</h1>
|
||||
<button className="monitor-refresh-button" onClick={fetchMetrics}>
|
||||
<span className="monitor-refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Monitor</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Real-time system metrics and performance monitoring
|
||||
</p>
|
||||
</div>
|
||||
<div className="monitor-controls">
|
||||
<select
|
||||
value={timeRange}
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="monitor-time-range-select"
|
||||
className="input"
|
||||
>
|
||||
<option value="1h">Last Hour</option>
|
||||
<option value="6h">Last 6 Hours</option>
|
||||
<option value="24h">Last 24 Hours</option>
|
||||
<option value="7d">Last 7 Days</option>
|
||||
<option value="30d">Last 30 Days</option>
|
||||
</select>
|
||||
<button onClick={fetchMetrics} className="btn btn-secondary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="monitor-error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="monitor-loading-spinner">
|
||||
<div className="monitor-spinner"></div>
|
||||
<p>Loading metrics...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading metrics...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="monitor-metrics-grid">
|
||||
<div className="monitor-metric-card">
|
||||
<h3>Total Requests</h3>
|
||||
<div className="monitor-metric-value">
|
||||
{metrics.length > 0
|
||||
? metrics.reduce((sum, m) => sum + m.requests, 0).toLocaleString()
|
||||
: '0'
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
/* Metrics Grid */
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Total Requests */}
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Total Requests</p>
|
||||
<p className="stats-value">1,234,567</p>
|
||||
<p className="stats-change positive">+12.5% from last period</p>
|
||||
</div>
|
||||
{renderMetricChart(
|
||||
metrics.length > 0
|
||||
? metrics.map(m => ({ timestamp: m.timestamp, value: m.requests }))
|
||||
: [],
|
||||
'Total Requests'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="monitor-metric-card">
|
||||
<h3>Error Rate</h3>
|
||||
<div className="monitor-metric-value">
|
||||
{metrics.length > 0
|
||||
? ((metrics.reduce((sum, m) => sum + m.errors, 0) /
|
||||
metrics.reduce((sum, m) => sum + m.requests, 0)) * 100).toFixed(2)
|
||||
: '0.00'
|
||||
}%
|
||||
</div>
|
||||
{renderMetricChart(
|
||||
metrics.length > 0
|
||||
? metrics.map(m => ({ timestamp: m.timestamp, value: m.errors }))
|
||||
: [],
|
||||
'Error Rate'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="monitor-metric-card">
|
||||
<h3>Average Response Time</h3>
|
||||
<div className="monitor-metric-value">
|
||||
{metrics.length > 0
|
||||
? Math.round(metrics.reduce((sum, m) => sum + m.avgResponseTime, 0) / metrics.length)
|
||||
: '0'
|
||||
}ms
|
||||
</div>
|
||||
{renderMetricChart(
|
||||
metrics.length > 0
|
||||
? metrics.map(m => ({ timestamp: m.timestamp, value: m.avgResponseTime }))
|
||||
: [],
|
||||
'Average Response Time'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="monitor-metric-card">
|
||||
<h3>Active Users</h3>
|
||||
<div className="monitor-metric-value">
|
||||
{metrics.length > 0
|
||||
? Math.max(...metrics.map(m => m.activeUsers))
|
||||
: '0'
|
||||
}
|
||||
</div>
|
||||
{renderMetricChart(
|
||||
metrics.length > 0
|
||||
? metrics.map(m => ({ timestamp: m.timestamp, value: m.activeUsers }))
|
||||
: [],
|
||||
'Active Users'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="monitor-metric-card">
|
||||
<h3>Bandwidth Usage</h3>
|
||||
<div className="monitor-metric-value">
|
||||
{metrics.length > 0
|
||||
? (metrics.reduce((sum, m) => sum + m.bandwidth, 0) / 1000000).toFixed(2)
|
||||
: '0.00'
|
||||
} MB
|
||||
</div>
|
||||
{renderMetricChart(
|
||||
metrics.length > 0
|
||||
? metrics.map(m => ({ timestamp: m.timestamp, value: m.bandwidth }))
|
||||
: [],
|
||||
'Bandwidth Usage'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="monitor-metric-card wide">
|
||||
<h3>Status Code Distribution</h3>
|
||||
<div className="monitor-status-codes">
|
||||
{metrics.length > 0 ? (
|
||||
Object.entries(metrics[metrics.length - 1].statusCodes).map(([code, count]) => (
|
||||
<div key={code} className="monitor-status-code-item">
|
||||
<span className="monitor-status-code-label">{code}</span>
|
||||
<span className="monitor-status-code-count">{Number(count)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="monitor-status-code-item">
|
||||
<span className="monitor-status-code-label">No data</span>
|
||||
<span className="monitor-status-code-count">0</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-12 w-12 rounded-lg bg-blue-100 dark:bg-blue-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Error Rate */}
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Error Rate</p>
|
||||
<p className="stats-value">0.23%</p>
|
||||
<p className="stats-change negative">+0.05% from last period</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Average Response Time */}
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Avg Response Time</p>
|
||||
<p className="stats-value">142ms</p>
|
||||
<p className="stats-change positive">-8ms from last period</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Users */}
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Active Users</p>
|
||||
<p className="stats-value">847</p>
|
||||
<p className="stats-change positive">+23 from last period</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-purple-100 dark:bg-purple-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{renderMetricChart([], 'Request Volume')}
|
||||
{renderMetricChart([], 'Response Times')}
|
||||
{renderMetricChart([], 'Error Rates')}
|
||||
{renderMetricChart([], 'Bandwidth Usage')}
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">System Status</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="h-3 w-3 bg-green-500 rounded-full mr-3"></div>
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">API Gateway</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400">Operational</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="h-3 w-3 bg-green-500 rounded-full mr-3"></div>
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">Database</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400">Operational</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="h-3 w-3 bg-green-500 rounded-full mr-3"></div>
|
||||
<div>
|
||||
<p className="font-medium text-green-900 dark:text-green-100">Cache</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400">Operational</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default MonitorPage;
|
||||
export default MonitorPage
|
||||
@@ -1,86 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import '../roles.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Role {
|
||||
role_name: string;
|
||||
role_description: string;
|
||||
manage_users?: boolean;
|
||||
manage_apis?: boolean;
|
||||
manage_endpoints?: boolean;
|
||||
manage_groups?: boolean;
|
||||
manage_roles?: boolean;
|
||||
manage_routings?: boolean;
|
||||
manage_gateway?: boolean;
|
||||
manage_subscriptions?: boolean;
|
||||
view_logs?: boolean;
|
||||
export_logs?: boolean;
|
||||
role_name: string
|
||||
role_description: string
|
||||
manage_users?: boolean
|
||||
manage_apis?: boolean
|
||||
manage_endpoints?: boolean
|
||||
manage_groups?: boolean
|
||||
manage_roles?: boolean
|
||||
manage_routings?: boolean
|
||||
manage_gateway?: boolean
|
||||
manage_subscriptions?: boolean
|
||||
view_logs?: boolean
|
||||
export_logs?: boolean
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const RoleDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const roleName = params.roleName as string;
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const roleName = params.roleName as string
|
||||
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<Partial<Role>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||||
const [role, setRole] = useState<Role | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editData, setEditData] = useState<Partial<Role>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRole();
|
||||
}, [roleName]);
|
||||
fetchRole()
|
||||
}, [roleName])
|
||||
|
||||
const fetchRole = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Try to get from sessionStorage first
|
||||
const savedRole = sessionStorage.getItem('selectedRole');
|
||||
const savedRole = sessionStorage.getItem('selectedRole')
|
||||
if (savedRole) {
|
||||
const parsedRole = JSON.parse(savedRole);
|
||||
const parsedRole = JSON.parse(savedRole)
|
||||
if (parsedRole.role_name === roleName) {
|
||||
setRole(parsedRole);
|
||||
setEditData(parsedRole);
|
||||
setLoading(false);
|
||||
return;
|
||||
setRole(parsedRole)
|
||||
setEditData(parsedRole)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,35 +65,39 @@ const RoleDetailPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load role');
|
||||
throw new Error('Failed to load role')
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setRole(data);
|
||||
setEditData(data);
|
||||
const data = await response.json()
|
||||
setRole(data)
|
||||
setEditData(data)
|
||||
} catch (err) {
|
||||
setError('Failed to load role. Please try again later.');
|
||||
setError('Failed to load role. Please try again later.')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.push('/roles')
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditData(role || {});
|
||||
};
|
||||
setIsEditing(false)
|
||||
setEditData(role || {})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/role/${encodeURIComponent(roleName)}`, {
|
||||
method: 'PUT',
|
||||
@@ -131,26 +108,38 @@ const RoleDetailPage = () => {
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(editData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to update role');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error_message || 'Failed to update role')
|
||||
}
|
||||
|
||||
setRole({ ...role, ...editData } as Role);
|
||||
setIsEditing(false);
|
||||
const updatedRole = await response.json()
|
||||
setRole(updatedRole)
|
||||
setIsEditing(false)
|
||||
setSuccess('Role updated successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update role. Please try again later.');
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to update role. Please try again later.')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteConfirmation !== role?.role_name) {
|
||||
setError('Role name does not match')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/role/${encodeURIComponent(roleName)}`, {
|
||||
method: 'DELETE',
|
||||
@@ -160,280 +149,319 @@ const RoleDetailPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete role');
|
||||
throw new Error('Failed to delete role')
|
||||
}
|
||||
|
||||
router.push('/roles');
|
||||
router.push('/roles')
|
||||
} catch (err) {
|
||||
setError('Failed to delete role. Please try again later.');
|
||||
setShowDeleteModal(false);
|
||||
setError('Failed to delete role. Please try again later.')
|
||||
setShowDeleteModal(false)
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleting(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof Role, value: any) => {
|
||||
setEditData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
setEditData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handlePermissionChange = (permission: keyof Role, value: boolean) => {
|
||||
setEditData(prev => ({ ...prev, [permission]: value }));
|
||||
};
|
||||
setEditData(prev => ({ ...prev, [permission]: value }))
|
||||
}
|
||||
|
||||
const getPermissionCount = (roleData: Role) => {
|
||||
return Object.values(roleData).filter(val => typeof val === 'boolean' && val).length;
|
||||
};
|
||||
return Object.values(roleData).filter(val => typeof val === 'boolean' && val).length
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<div className="roles-topbar">Doorman</div>
|
||||
<div className="roles-root">
|
||||
<aside className="roles-sidebar">
|
||||
<div className="roles-sidebar-title">Menu</div>
|
||||
<ul className="roles-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="roles-logout-btn" onClick={handleLogout}>Logout</button>
|
||||
</aside>
|
||||
<main className="roles-main">
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading role...</p>
|
||||
</div>
|
||||
</main>
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading role details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
if (error && !role) {
|
||||
return (
|
||||
<>
|
||||
<div className="roles-topbar">Doorman</div>
|
||||
<div className="roles-root">
|
||||
<aside className="roles-sidebar">
|
||||
<div className="roles-sidebar-title">Menu</div>
|
||||
<ul className="roles-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="roles-logout-btn" onClick={handleLogout}>Logout</button>
|
||||
</aside>
|
||||
<main className="roles-main">
|
||||
<div className="error-container">
|
||||
<div className="error-message">Role not found</div>
|
||||
<Link href="/roles" className="back-link">Back to Roles</Link>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Role Details</h1>
|
||||
</div>
|
||||
</main>
|
||||
<button onClick={handleBack} className="btn btn-secondary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Roles
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="roles-topbar">Doorman</div>
|
||||
<div className="roles-root">
|
||||
<aside className="roles-sidebar">
|
||||
<div className="roles-sidebar-title">Menu</div>
|
||||
<ul className="roles-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="roles-logout-btn" onClick={handleLogout}>Logout</button>
|
||||
</aside>
|
||||
<main className="roles-main">
|
||||
<div className="roles-header">
|
||||
<button className="back-button" onClick={() => router.push('/roles')}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Roles
|
||||
</button>
|
||||
<h1 className="roles-title">Role Details</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{role?.role_name || 'Role Details'}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage role permissions and settings
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isEditing ? (
|
||||
<button className="edit-button" onClick={handleEdit}>
|
||||
Edit Role
|
||||
</button>
|
||||
) : (
|
||||
<div className="edit-actions">
|
||||
<button className="save-button" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
<>
|
||||
<button onClick={handleEdit} className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit Role
|
||||
</button>
|
||||
<button className="cancel-button" onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="delete-button" onClick={() => setShowDeleteModal(true)} disabled={saving}>
|
||||
<button onClick={() => setShowDeleteModal(true)} className="btn btn-error">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete Role
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="roles-detail-content">
|
||||
<div className="roles-detail-card">
|
||||
<div className="roles-detail-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label className="info-label">Role Name</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="edit-input"
|
||||
value={editData.role_name || ''}
|
||||
onChange={(e) => handleInputChange('role_name', e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
) : (
|
||||
<span className="info-value">{role.role_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label className="info-label">Description</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="edit-input"
|
||||
value={editData.role_description || ''}
|
||||
onChange={(e) => handleInputChange('role_description', e.target.value)}
|
||||
disabled={saving}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<span className="info-value">{role.role_description || 'No description'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="roles-detail-section">
|
||||
<h2 className="section-title">Permissions</h2>
|
||||
|
||||
<div className="permissions-grid">
|
||||
{[
|
||||
{ key: 'manage_users', label: 'Manage Users' },
|
||||
{ key: 'manage_apis', label: 'Manage APIs' },
|
||||
{ key: 'manage_endpoints', label: 'Manage Endpoints' },
|
||||
{ key: 'manage_groups', label: 'Manage Groups' },
|
||||
{ key: 'manage_roles', label: 'Manage Roles' },
|
||||
{ key: 'manage_routings', label: 'Manage Routings' },
|
||||
{ key: 'manage_gateway', label: 'Manage Gateway' },
|
||||
{ key: 'manage_subscriptions', label: 'Manage Subscriptions' },
|
||||
{ key: 'view_logs', label: 'View Logs' },
|
||||
{ key: 'export_logs', label: 'Export Logs' }
|
||||
].map(({ key, label }) => (
|
||||
<div key={key} className="permission-item">
|
||||
{isEditing ? (
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editData[key as keyof Role] as boolean || false}
|
||||
onChange={(e) => handlePermissionChange(key as keyof Role, e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span className="permission-text">{label}</span>
|
||||
</label>
|
||||
) : (
|
||||
<div className="permission-display">
|
||||
<span className={`permission-badge ${(role[key as keyof Role] as boolean) ? 'enabled' : 'disabled'}`}>
|
||||
{(role[key as keyof Role] as boolean) ? '✓' : '✗'} {label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
|
||||
{saving ? (
|
||||
<div className="flex items-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Saving...
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={handleCancel} className="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleBack} className="btn btn-ghost">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="rounded-lg bg-success-50 border border-success-200 p-4 dark:bg-success-900/20 dark:border-success-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-success-400 dark:text-success-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-success-700 dark:text-success-300">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Role Name
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.role_name || ''}
|
||||
onChange={(e) => handleInputChange('role_name', e.target.value)}
|
||||
className="input"
|
||||
placeholder="Enter role name"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-900 dark:text-white">{role.role_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editData.role_description || ''}
|
||||
onChange={(e) => handleInputChange('role_description', e.target.value)}
|
||||
className="input resize-none"
|
||||
rows={3}
|
||||
placeholder="Enter role description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-600 dark:text-gray-400">{role.role_description || 'No description'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="permissions-summary">
|
||||
<span className="permission-badge">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Total Permissions
|
||||
</label>
|
||||
<span className="badge badge-primary">
|
||||
{getPermissionCount(role)} permission{getPermissionCount(role) !== 1 ? 's' : ''} enabled
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Delete Role</h2>
|
||||
<button className="modal-close" onClick={() => setShowDeleteModal(false)}>
|
||||
×
|
||||
</button>
|
||||
{/* Permissions */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Permissions</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{[
|
||||
{ key: 'manage_users', label: 'Manage Users', description: 'Create, edit, and delete user accounts' },
|
||||
{ key: 'manage_apis', label: 'Manage APIs', description: 'Create, edit, and delete API configurations' },
|
||||
{ key: 'manage_endpoints', label: 'Manage Endpoints', description: 'Configure API endpoints and validations' },
|
||||
{ key: 'manage_groups', label: 'Manage Groups', description: 'Create, edit, and delete user groups' },
|
||||
{ key: 'manage_roles', label: 'Manage Roles', description: 'Create, edit, and delete user roles' },
|
||||
{ key: 'manage_routings', label: 'Manage Routings', description: 'Configure API routing and load balancing' },
|
||||
{ key: 'manage_gateway', label: 'Manage Gateway', description: 'Configure gateway settings and policies' },
|
||||
{ key: 'manage_subscriptions', label: 'Manage Subscriptions', description: 'Manage API subscriptions and billing' },
|
||||
{ key: 'view_logs', label: 'View Logs', description: 'View system logs and API requests' },
|
||||
{ key: 'export_logs', label: 'Export Logs', description: 'Export logs in various formats' }
|
||||
].map(({ key, label, description }) => (
|
||||
<div key={key} className="flex items-start space-x-3">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
checked={editData[key as keyof Role] as boolean || false}
|
||||
onChange={(e) => handlePermissionChange(key as keyof Role, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded mt-1"
|
||||
disabled={saving}
|
||||
/>
|
||||
) : (
|
||||
<div className={`h-4 w-4 rounded mt-1 flex items-center justify-center ${
|
||||
(role[key as keyof Role] as boolean)
|
||||
? 'bg-success-500 text-white'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}>
|
||||
{(role[key as keyof Role] as boolean) && (
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<label htmlFor={key} className={`block text-sm font-medium ${isEditing ? 'text-gray-700 dark:text-gray-300 cursor-pointer' : 'text-gray-900 dark:text-white'}`}>
|
||||
{label}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p className="modal-message">
|
||||
This action cannot be undone. This will permanently delete the role <strong>{role?.role_name}</strong>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={() => setShowDeleteModal(false)}></div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Delete Role</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
This action cannot be undone. This will permanently delete the role "{role?.role_name}".
|
||||
</p>
|
||||
<p className="modal-warning">
|
||||
To confirm deletion, please type the role name <strong>{role?.role_name}</strong> in the field below:
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Please type <strong>{role?.role_name}</strong> to confirm.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
className="modal-input"
|
||||
placeholder="Enter role name to confirm"
|
||||
value={deleteConfirmation}
|
||||
onChange={(e) => setDeleteConfirmation(e.target.value)}
|
||||
autoFocus
|
||||
className="input w-full mb-4"
|
||||
placeholder="Enter role name to confirm"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="modal-cancel-button"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="modal-delete-button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || deleteConfirmation !== role?.role_name}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Role'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteConfirmation !== role?.role_name || deleting}
|
||||
className="btn btn-error flex-1"
|
||||
>
|
||||
{deleting ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Deleting...
|
||||
</div>
|
||||
) : (
|
||||
'Delete Role'
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => setShowDeleteModal(false)} className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleDetailPage;
|
||||
export default RoleDetailPage
|
||||
@@ -1,49 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import '../roles.css';
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface CreateRoleData {
|
||||
role_name: string;
|
||||
role_description: string;
|
||||
manage_users: boolean;
|
||||
manage_apis: boolean;
|
||||
manage_endpoints: boolean;
|
||||
manage_groups: boolean;
|
||||
manage_roles: boolean;
|
||||
manage_routings: boolean;
|
||||
manage_gateway: boolean;
|
||||
manage_subscriptions: boolean;
|
||||
role_name: string
|
||||
role_description: string
|
||||
manage_users: boolean
|
||||
manage_apis: boolean
|
||||
manage_endpoints: boolean
|
||||
manage_groups: boolean
|
||||
manage_roles: boolean
|
||||
manage_routings: boolean
|
||||
manage_gateway: boolean
|
||||
manage_subscriptions: boolean
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const AddRolePage = () => {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<CreateRoleData>({
|
||||
role_name: '',
|
||||
role_description: '',
|
||||
@@ -55,29 +33,23 @@ const AddRolePage = () => {
|
||||
manage_routings: false,
|
||||
manage_gateway: false,
|
||||
manage_subscriptions: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
})
|
||||
|
||||
const handleInputChange = (field: keyof CreateRoleData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.role_name.trim()) {
|
||||
setError('Role name is required');
|
||||
return;
|
||||
setError('Role name is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('http://localhost:3002/platform/role', {
|
||||
method: 'POST',
|
||||
@@ -88,195 +60,155 @@ const AddRolePage = () => {
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to create role');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to create role')
|
||||
}
|
||||
|
||||
// Redirect back to roles list after successful creation
|
||||
router.push('/roles');
|
||||
router.push('/roles')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create role');
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to create role. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const permissions = [
|
||||
{ key: 'manage_users', label: 'Manage Users', description: 'Create, edit, and delete user accounts' },
|
||||
{ key: 'manage_apis', label: 'Manage APIs', description: 'Create, edit, and delete API configurations' },
|
||||
{ key: 'manage_endpoints', label: 'Manage Endpoints', description: 'Configure API endpoints and validations' },
|
||||
{ key: 'manage_groups', label: 'Manage Groups', description: 'Create, edit, and delete user groups' },
|
||||
{ key: 'manage_roles', label: 'Manage Roles', description: 'Create, edit, and delete user roles' },
|
||||
{ key: 'manage_routings', label: 'Manage Routings', description: 'Configure API routing and load balancing' },
|
||||
{ key: 'manage_gateway', label: 'Manage Gateway', description: 'Configure gateway settings and policies' },
|
||||
{ key: 'manage_subscriptions', label: 'Manage Subscriptions', description: 'Manage API subscriptions and billing' }
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="roles-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="roles-root">
|
||||
<aside className="roles-sidebar">
|
||||
<div className="roles-sidebar-title">Menu</div>
|
||||
<ul className="roles-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="roles-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="roles-main">
|
||||
<div className="roles-header-row">
|
||||
<Link href="/roles" className="back-button" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Roles
|
||||
</Link>
|
||||
<h1 className="roles-title">Add Role</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Add Role</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Create a new user role with specific permissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-role-form">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Role Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.role_name}
|
||||
onChange={(e) => handleInputChange('role_name', e.target.value)}
|
||||
placeholder="Enter role name"
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.role_description}
|
||||
onChange={(e) => handleInputChange('role_description', e.target.value)}
|
||||
placeholder="Enter role description"
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Permissions</h2>
|
||||
<div className="permissions-grid">
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_users}
|
||||
onChange={(e) => handleInputChange('manage_users', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage Users</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_apis}
|
||||
onChange={(e) => handleInputChange('manage_apis', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage APIs</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_endpoints}
|
||||
onChange={(e) => handleInputChange('manage_endpoints', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage Endpoints</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_groups}
|
||||
onChange={(e) => handleInputChange('manage_groups', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage Groups</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_roles}
|
||||
onChange={(e) => handleInputChange('manage_roles', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage Roles</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_routings}
|
||||
onChange={(e) => handleInputChange('manage_routings', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage Routings</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_gateway}
|
||||
onChange={(e) => handleInputChange('manage_gateway', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage Gateway</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="permission-item">
|
||||
<label className="permission-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.manage_subscriptions}
|
||||
onChange={(e) => handleInputChange('manage_subscriptions', e.target.checked)}
|
||||
/>
|
||||
<span className="permission-text">Manage Subscriptions</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<Link href="/roles" className="cancel-button" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className="save-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)}
|
||||
|
||||
export default AddRolePage;
|
||||
{/* Form */}
|
||||
<div className="card max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="role_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Role Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="role_name"
|
||||
name="role_name"
|
||||
className="input"
|
||||
placeholder="Enter role name"
|
||||
value={formData.role_name}
|
||||
onChange={(e) => handleInputChange('role_name', e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role_description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="role_description"
|
||||
name="role_description"
|
||||
rows={4}
|
||||
className="input resize-none"
|
||||
placeholder="Describe the purpose of this role..."
|
||||
value={formData.role_description}
|
||||
onChange={(e) => handleInputChange('role_description', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Optional description of the role's purpose
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
|
||||
Permissions
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{permissions.map((permission) => (
|
||||
<div key={permission.key} className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={permission.key}
|
||||
name={permission.key}
|
||||
checked={formData[permission.key as keyof CreateRoleData] as boolean}
|
||||
onChange={(e) => handleInputChange(permission.key as keyof CreateRoleData, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded mt-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={permission.key} className="block text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
{permission.label}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{permission.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Creating Role...
|
||||
</div>
|
||||
) : (
|
||||
'Create Role'
|
||||
)}
|
||||
</button>
|
||||
<Link href="/roles" className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRolePage
|
||||
+175
-157
@@ -1,68 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import './roles.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Role {
|
||||
role_name: string;
|
||||
role_description: string;
|
||||
manage_users?: boolean;
|
||||
manage_apis?: boolean;
|
||||
manage_endpoints?: boolean;
|
||||
manage_groups?: boolean;
|
||||
manage_roles?: boolean;
|
||||
manage_routings?: boolean;
|
||||
manage_gateway?: boolean;
|
||||
manage_subscriptions?: boolean;
|
||||
role_name: string
|
||||
role_description: string
|
||||
manage_users?: boolean
|
||||
manage_apis?: boolean
|
||||
manage_endpoints?: boolean
|
||||
manage_groups?: boolean
|
||||
manage_roles?: boolean
|
||||
manage_routings?: boolean
|
||||
manage_gateway?: boolean
|
||||
manage_subscriptions?: boolean
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const RolesPage = () => {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [allRoles, setAllRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('role_name');
|
||||
const router = useRouter()
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState('role_name')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, []);
|
||||
fetchRoles()
|
||||
}, [])
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`http://localhost:3002/platform/role/all?page=1&page_size=10`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
@@ -70,173 +42,219 @@ const RolesPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load roles');
|
||||
throw new Error('Failed to load roles')
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
|
||||
setAllRoles(data.roles);
|
||||
setRoles(data.roles);
|
||||
setAllRoles(data.roles)
|
||||
setRoles(data.roles)
|
||||
} catch (err) {
|
||||
setError('Failed to load roles. Please try again later.');
|
||||
setRoles([]);
|
||||
setAllRoles([]);
|
||||
setError('Failed to load roles. Please try again later.')
|
||||
setRoles([])
|
||||
setAllRoles([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!searchTerm.trim()) {
|
||||
setRoles(allRoles);
|
||||
return;
|
||||
setRoles(allRoles)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredRoles = allRoles.filter(role =>
|
||||
role.role_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
role.role_description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
setRoles(filteredRoles);
|
||||
};
|
||||
)
|
||||
setRoles(filteredRoles)
|
||||
}
|
||||
|
||||
const handleSort = (sortField: string) => {
|
||||
setSortBy(sortField);
|
||||
setSortBy(sortField)
|
||||
const sortedRoles = [...roles].sort((a, b) => {
|
||||
if (sortField === 'role_name') {
|
||||
return a.role_name.localeCompare(b.role_name);
|
||||
return a.role_name.localeCompare(b.role_name)
|
||||
} else if (sortField === 'permissions') {
|
||||
const aPerms = Object.values(a).filter(val => typeof val === 'boolean' && val).length;
|
||||
const bPerms = Object.values(b).filter(val => typeof val === 'boolean' && val).length;
|
||||
return bPerms - aPerms;
|
||||
const aPerms = Object.values(a).filter(val => typeof val === 'boolean' && val).length
|
||||
const bPerms = Object.values(b).filter(val => typeof val === 'boolean' && val).length
|
||||
return bPerms - aPerms
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setRoles(sortedRoles);
|
||||
};
|
||||
return 0
|
||||
})
|
||||
setRoles(sortedRoles)
|
||||
}
|
||||
|
||||
const handleRoleClick = (role: Role) => {
|
||||
// Store role data in sessionStorage for the detail page
|
||||
sessionStorage.setItem('selectedRole', JSON.stringify(role));
|
||||
router.push(`/roles/${role.role_name}`);
|
||||
};
|
||||
sessionStorage.setItem('selectedRole', JSON.stringify(role))
|
||||
router.push(`/roles/${role.role_name}`)
|
||||
}
|
||||
|
||||
const getPermissionCount = (role: Role) => {
|
||||
return Object.values(role).filter(val => typeof val === 'boolean' && val).length;
|
||||
};
|
||||
return Object.values(role).filter(val => typeof val === 'boolean' && val).length
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="roles-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="roles-root">
|
||||
<aside className="roles-sidebar">
|
||||
<div className="roles-sidebar-title">Menu</div>
|
||||
<ul className="roles-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`roles-sidebar-item${idx === 5 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="roles-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="roles-main">
|
||||
<div className="roles-header-row">
|
||||
<h1 className="roles-title">Roles</h1>
|
||||
<button className="refresh-button" onClick={fetchRoles}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Roles</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage user roles and permissions
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/roles/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Role
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="roles-controls">
|
||||
<form className="roles-search-box" onSubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className="roles-search-input"
|
||||
placeholder="Search roles..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="roles-search-btn">Search</button>
|
||||
<Link href="/roles/add" className="roles-add-btn" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Add Role
|
||||
</Link>
|
||||
{/* Search and Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search roles by name or description..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="roles-sort-group">
|
||||
<button
|
||||
className={`roles-sort-btn ${sortBy === 'role_name' ? 'active' : ''}`}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSort('role_name')}
|
||||
className={`btn ${sortBy === 'role_name' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Name
|
||||
</button>
|
||||
<button
|
||||
className={`roles-sort-btn ${sortBy === 'permissions' ? 'active' : ''}`}
|
||||
<button
|
||||
onClick={() => handleSort('permissions')}
|
||||
className={`btn ${sortBy === 'permissions' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Permissions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading roles...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading roles...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="roles-table-panel">
|
||||
<table className="roles-table">
|
||||
</div>
|
||||
) : (
|
||||
/* Roles Table */
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Permissions</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map((role) => (
|
||||
<tr
|
||||
key={role.role_name}
|
||||
key={role.role_name}
|
||||
onClick={() => handleRoleClick(role)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors"
|
||||
>
|
||||
<td><b>{role.role_name}</b></td>
|
||||
<td>{role.role_description || 'No description'}</td>
|
||||
<td>
|
||||
<span className="roles-permissions-badge">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-purple-400 to-purple-600 flex items-center justify-center mr-3">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{role.role_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{role.role_description || 'No description'}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-warning">
|
||||
{getPermissionCount(role)} permission{getPermissionCount(role) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RolesPage;
|
||||
{/* Empty State */}
|
||||
{roles.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-16 w-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No roles found</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{searchTerm ? 'Try adjusting your search terms.' : 'Get started by creating your first user role.'}
|
||||
</p>
|
||||
<Link href="/roles/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Role
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default RolesPage
|
||||
@@ -1,111 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import './routing-detail.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Routing {
|
||||
routing_name: string;
|
||||
routing_servers: string[];
|
||||
routing_description: string;
|
||||
client_key: string;
|
||||
server_index?: number;
|
||||
routing_name: string
|
||||
routing_servers: string[]
|
||||
routing_description: string
|
||||
client_key: string
|
||||
server_index?: number
|
||||
}
|
||||
|
||||
interface UpdateRoutingData {
|
||||
routing_name?: string;
|
||||
routing_servers?: string[];
|
||||
routing_description?: string;
|
||||
server_index?: number;
|
||||
routing_name?: string
|
||||
routing_servers?: string[]
|
||||
routing_description?: string
|
||||
server_index?: number
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const RoutingDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const clientKey = params.clientKey as string;
|
||||
const [routing, setRouting] = useState<Routing | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editData, setEditData] = useState<UpdateRoutingData>({});
|
||||
const [newServer, setNewServer] = useState('');
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const clientKey = params.clientKey as string
|
||||
const [routing, setRouting] = useState<Routing | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editData, setEditData] = useState<UpdateRoutingData>({})
|
||||
const [newServer, setNewServer] = useState('')
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const routingData = sessionStorage.getItem('selectedRouting');
|
||||
const routingData = sessionStorage.getItem('selectedRouting')
|
||||
if (routingData) {
|
||||
try {
|
||||
const parsedRouting = JSON.parse(routingData);
|
||||
setRouting(parsedRouting);
|
||||
const parsedRouting = JSON.parse(routingData)
|
||||
setRouting(parsedRouting)
|
||||
setEditData({
|
||||
routing_name: parsedRouting.routing_name,
|
||||
routing_servers: [...parsedRouting.routing_servers],
|
||||
routing_description: parsedRouting.routing_description,
|
||||
server_index: parsedRouting.server_index || 0
|
||||
});
|
||||
setLoading(false);
|
||||
})
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
setError('Failed to load routing data');
|
||||
setLoading(false);
|
||||
setError('Failed to load routing data')
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
setError('No routing data found');
|
||||
setLoading(false);
|
||||
setError('No routing data found')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [clientKey]);
|
||||
}, [clientKey])
|
||||
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
router.push('/routings')
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setError(null);
|
||||
// Reset edit data to original values
|
||||
setIsEditing(false)
|
||||
if (routing) {
|
||||
setEditData({
|
||||
routing_name: routing.routing_name,
|
||||
routing_servers: [...routing.routing_servers],
|
||||
routing_description: routing.routing_description,
|
||||
server_index: routing.server_index || 0
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/routing/${clientKey}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
@@ -115,76 +93,76 @@ const RoutingDetailPage = () => {
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(editData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to update routing');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to update routing')
|
||||
}
|
||||
|
||||
// Update the routing data in sessionStorage and state
|
||||
if (routing) {
|
||||
const updatedRouting: Routing = {
|
||||
...routing,
|
||||
...editData,
|
||||
routing_name: editData.routing_name || routing.routing_name,
|
||||
routing_servers: editData.routing_servers || routing.routing_servers,
|
||||
routing_description: editData.routing_description || routing.routing_description,
|
||||
server_index: editData.server_index ?? routing.server_index
|
||||
};
|
||||
sessionStorage.setItem('selectedRouting', JSON.stringify(updatedRouting));
|
||||
setRouting(updatedRouting);
|
||||
}
|
||||
setIsEditing(false);
|
||||
const updatedRouting = await response.json()
|
||||
setRouting(updatedRouting)
|
||||
setIsEditing(false)
|
||||
setSuccess('Routing updated successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update routing');
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to update routing. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSaving(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof UpdateRoutingData, value: any) => {
|
||||
setEditData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleServerChange = (index: number, value: string) => {
|
||||
const newServers = [...(editData.routing_servers || [])];
|
||||
newServers[index] = value;
|
||||
setEditData(prev => ({ ...prev, routing_servers: newServers }));
|
||||
};
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
routing_servers: prev.routing_servers?.map((server, i) => i === index ? value : server) || []
|
||||
}))
|
||||
}
|
||||
|
||||
const addServer = () => {
|
||||
if (newServer.trim()) {
|
||||
if (newServer.trim() && !editData.routing_servers?.includes(newServer.trim())) {
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
routing_servers: [...(prev.routing_servers || []), newServer.trim()]
|
||||
}));
|
||||
setNewServer('');
|
||||
}))
|
||||
setNewServer('')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const removeServer = (index: number) => {
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
routing_servers: (prev.routing_servers || []).filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
routing_servers: prev.routing_servers?.filter((_, i) => i !== index) || []
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmation('');
|
||||
};
|
||||
setShowDeleteModal(false)
|
||||
setDeleteConfirmation('')
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (deleteConfirmation !== routing?.routing_name) {
|
||||
setError('Confirmation does not match routing name');
|
||||
return;
|
||||
setError('Routing name does not match')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`http://localhost:3002/platform/routing/${clientKey}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
@@ -193,254 +171,329 @@ const RoutingDetailPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to delete routing');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to delete routing')
|
||||
}
|
||||
|
||||
// Redirect to routings list after successful deletion
|
||||
router.push('/routings');
|
||||
router.push('/routings')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete routing');
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to delete routing. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleting(false)
|
||||
setShowDeleteModal(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading routing...</p>
|
||||
</div>
|
||||
);
|
||||
<Layout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading routing details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !routing) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Routing Details</h1>
|
||||
</div>
|
||||
<button onClick={handleBack} className="btn btn-secondary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Routings
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Routings
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="routings-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="routings-root">
|
||||
<aside className="routings-sidebar">
|
||||
<div className="routings-sidebar-title">Menu</div>
|
||||
<ul className="routings-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`routings-sidebar-item${idx === 2 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`routings-sidebar-item${idx === 2 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="routings-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="routings-main">
|
||||
<div className="routing-detail-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Routings
|
||||
</button>
|
||||
<h1 className="routing-detail-title">Routing Details</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{routing?.routing_name || 'Routing Details'}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage routing configuration and load balancing
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isEditing ? (
|
||||
<button className="edit-button" onClick={handleEdit}>
|
||||
Edit Routing
|
||||
</button>
|
||||
) : (
|
||||
<div className="edit-actions">
|
||||
<button className="save-button" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
<>
|
||||
<button onClick={handleEdit} className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit Routing
|
||||
</button>
|
||||
<button className="cancel-button" onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="delete-button" onClick={handleDeleteClick} disabled={saving}>
|
||||
<button onClick={handleDeleteClick} className="btn btn-error">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete Routing
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary">
|
||||
{saving ? (
|
||||
<div className="flex items-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Saving...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={handleCancel} className="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleBack} className="btn btn-ghost">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="rounded-lg bg-success-50 border border-success-200 p-4 dark:bg-success-900/20 dark:border-success-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-success-400 dark:text-success-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-success-700 dark:text-success-300">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{routing && (
|
||||
<div className="routing-detail-content">
|
||||
<div className="routing-detail-form">
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Routing Name</label>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{routing && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Information */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Basic Information</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Routing Name
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.routing_name || ''}
|
||||
onChange={(e) => handleInputChange('routing_name', e.target.value)}
|
||||
className="input"
|
||||
placeholder="Enter routing name"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-900 dark:text-white">{routing.routing_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Key
|
||||
</label>
|
||||
<code className="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded font-mono">
|
||||
{routing.client_key}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editData.routing_description || ''}
|
||||
onChange={(e) => handleInputChange('routing_description', e.target.value)}
|
||||
className="input resize-none"
|
||||
rows={3}
|
||||
placeholder="Enter routing description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-600 dark:text-gray-400">{routing.routing_description || 'No description'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server Index
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.server_index || 0}
|
||||
onChange={(e) => handleInputChange('server_index', parseInt(e.target.value))}
|
||||
className="input"
|
||||
min="0"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-900 dark:text-white">{routing.server_index || 0}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Servers Configuration */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Servers Configuration</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{isEditing && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newServer}
|
||||
onChange={(e) => setNewServer(e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder="Enter server URL"
|
||||
onKeyPress={(e) => e.key === 'Enter' && addServer()}
|
||||
/>
|
||||
<button onClick={addServer} className="btn btn-primary">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{(isEditing ? editData.routing_servers : routing.routing_servers)?.map((server, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editData.routing_name || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, routing_name: e.target.value }))}
|
||||
maxLength={50}
|
||||
value={server}
|
||||
onChange={(e) => handleServerChange(index, e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder="Enter server URL"
|
||||
/>
|
||||
) : (
|
||||
<div className="form-value">{routing.routing_name}</div>
|
||||
<span className="text-sm font-mono text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded flex-1">
|
||||
{server}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => removeServer(index)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-200"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Client Key</label>
|
||||
<div className="form-value">{routing.client_key}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(!isEditing ? routing.routing_servers : editData.routing_servers)?.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No servers configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={handleDeleteCancel}></div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">Delete Routing</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
This action cannot be undone. This will permanently delete the routing "{routing?.routing_name}".
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Please type <strong>{routing?.routing_name}</strong> to confirm.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmation}
|
||||
onChange={(e) => setDeleteConfirmation(e.target.value)}
|
||||
className="input w-full mb-4"
|
||||
placeholder="Enter routing name to confirm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteConfirmation !== routing?.routing_name || deleting}
|
||||
className="btn btn-error flex-1"
|
||||
>
|
||||
{deleting ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Deleting...
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={editData.routing_description || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, routing_description: e.target.value }))}
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<div className="form-value">{routing.routing_description || 'No description'}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Server Configuration</h2>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Servers</label>
|
||||
{isEditing ? (
|
||||
<div className="servers-container">
|
||||
<div className="servers-list">
|
||||
{(editData.routing_servers || []).map((server, index) => (
|
||||
<div key={index} className="server-input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={server}
|
||||
onChange={(e) => handleServerChange(index, e.target.value)}
|
||||
placeholder="Server URL"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="remove-server-btn"
|
||||
onClick={() => removeServer(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-server">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter server URL"
|
||||
value={newServer}
|
||||
onChange={(e) => setNewServer(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="add-button"
|
||||
onClick={addServer}
|
||||
>
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="servers-list">
|
||||
{routing.routing_servers.map((server, index) => (
|
||||
<div key={index} className="server-tag">
|
||||
<span>{server}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Default Server Index</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={editData.server_index || 0}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, server_index: parseInt(e.target.value) || 0 }))}
|
||||
min={0}
|
||||
/>
|
||||
) : (
|
||||
<div className="form-value">{routing.server_index || 0}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
'Delete Routing'
|
||||
)}
|
||||
</button>
|
||||
<button onClick={handleDeleteCancel} className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<h3>Delete Routing</h3>
|
||||
<p>Are you sure you want to delete this routing? This action cannot be undone.</p>
|
||||
<p>To confirm, please type the routing name: <strong>{routing?.routing_name}</strong></p>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter routing name to confirm"
|
||||
value={deleteConfirmation}
|
||||
onChange={(e) => setDeleteConfirmation(e.target.value)}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-button"
|
||||
onClick={handleDeleteCancel}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="delete-button"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleting || deleteConfirmation !== routing?.routing_name}
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Routing'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoutingDetailPage;
|
||||
export default RoutingDetailPage
|
||||
@@ -1,79 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import '../routings.css';
|
||||
import './add-routing.css';
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface CreateRoutingData {
|
||||
routing_name: string;
|
||||
routing_servers: string[];
|
||||
routing_description: string;
|
||||
client_key?: string;
|
||||
server_index?: number;
|
||||
routing_name: string
|
||||
routing_servers: string[]
|
||||
routing_description: string
|
||||
client_key?: string
|
||||
server_index?: number
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const AddRoutingPage = () => {
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState<CreateRoutingData>({
|
||||
routing_name: '',
|
||||
routing_servers: [],
|
||||
routing_description: '',
|
||||
server_index: 0
|
||||
});
|
||||
const [newServer, setNewServer] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
})
|
||||
const [newServer, setNewServer] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (field: keyof CreateRoutingData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const addServer = () => {
|
||||
if (newServer.trim()) {
|
||||
setFormData(prev => ({ ...prev, routing_servers: [...prev.routing_servers, newServer.trim()] }));
|
||||
setNewServer('');
|
||||
if (newServer.trim() && !formData.routing_servers.includes(newServer.trim())) {
|
||||
setFormData(prev => ({ ...prev, routing_servers: [...prev.routing_servers, newServer.trim()] }))
|
||||
setNewServer('')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const removeServer = (index: number) => {
|
||||
setFormData(prev => ({ ...prev, routing_servers: prev.routing_servers.filter((_, i) => i !== index) }));
|
||||
};
|
||||
setFormData(prev => ({ ...prev, routing_servers: prev.routing_servers.filter((_, i) => i !== index) }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
// Validate required fields
|
||||
if (!formData.routing_name || formData.routing_servers.length === 0) {
|
||||
setError('Please fill in routing name and add at least one server');
|
||||
return;
|
||||
setError('Please fill in routing name and add at least one server')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('http://localhost:3002/platform/routing/', {
|
||||
method: 'POST',
|
||||
@@ -84,179 +62,184 @@ const AddRoutingPage = () => {
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to create routing');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to create routing')
|
||||
}
|
||||
|
||||
// Redirect to routings list after successful creation
|
||||
router.push('/routings');
|
||||
router.push('/routings')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create routing');
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to create routing. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="routings-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="routings-root">
|
||||
<aside className="routings-sidebar">
|
||||
<div className="routings-sidebar-title">Menu</div>
|
||||
<ul className="routings-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`routings-sidebar-item${idx === 2 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`routings-sidebar-item${idx === 2 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="routings-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="routings-main">
|
||||
<div className="routings-header-row">
|
||||
<div className="add-routing-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Routings
|
||||
</button>
|
||||
<h1 className="routings-title">Add New Routing</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Add Routing</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Create a new routing configuration for load balancing
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
{/* Form */}
|
||||
<div className="card max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="routing_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Routing Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="routing_name"
|
||||
name="routing_name"
|
||||
className="input"
|
||||
placeholder="Enter routing name"
|
||||
value={formData.routing_name}
|
||||
onChange={(e) => handleInputChange('routing_name', e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-routing-content">
|
||||
<form className="add-routing-form" onSubmit={handleSubmit}>
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Routing Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter routing name"
|
||||
value={formData.routing_name}
|
||||
onChange={(e) => handleInputChange('routing_name', e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Client Key (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter client key"
|
||||
value={formData.client_key || ''}
|
||||
onChange={(e) => handleInputChange('client_key', e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Description</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
placeholder="Enter routing description"
|
||||
value={formData.routing_description}
|
||||
onChange={(e) => handleInputChange('routing_description', e.target.value)}
|
||||
maxLength={255}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="routing_description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="routing_description"
|
||||
name="routing_description"
|
||||
rows={4}
|
||||
className="input resize-none"
|
||||
placeholder="Describe the purpose of this routing..."
|
||||
value={formData.routing_description}
|
||||
onChange={(e) => handleInputChange('routing_description', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Optional description of the routing configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Server Configuration</h2>
|
||||
<div className="servers-container">
|
||||
<div className="servers-list">
|
||||
{formData.routing_servers.map((server, index) => (
|
||||
<div key={index} className="server-tag">
|
||||
<span>{server}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="remove-server-btn"
|
||||
onClick={() => removeServer(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-server">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter server URL (e.g., http://localhost:8080)"
|
||||
value={newServer}
|
||||
onChange={(e) => setNewServer(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="add-button"
|
||||
onClick={addServer}
|
||||
>
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Default Server Index</label>
|
||||
<div>
|
||||
<label htmlFor="server_index" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Default Server Index
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="server_index"
|
||||
name="server_index"
|
||||
className="input"
|
||||
placeholder="0"
|
||||
value={formData.server_index || 0}
|
||||
onChange={(e) => handleInputChange('server_index', parseInt(e.target.value))}
|
||||
disabled={loading}
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Index of the default server in the list (0-based)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Servers *
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
placeholder="0"
|
||||
value={formData.server_index || 0}
|
||||
onChange={(e) => handleInputChange('server_index', parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder="Enter server URL (e.g., http://localhost:8080)"
|
||||
value={newServer}
|
||||
onChange={(e) => setNewServer(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addServer())}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addServer}
|
||||
disabled={loading || !newServer.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{formData.routing_servers.map((server, index) => (
|
||||
<div key={index} className="flex items-center justify-between bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded">
|
||||
<span className="text-sm font-mono text-gray-700 dark:text-gray-300">{server}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeServer(index)}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-200"
|
||||
disabled={loading}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{formData.routing_servers.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">No servers added yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-button"
|
||||
onClick={handleBack}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="save-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Routing'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<div className="flex gap-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Creating Routing...
|
||||
</div>
|
||||
) : (
|
||||
'Create Routing'
|
||||
)}
|
||||
</button>
|
||||
<Link href="/routings" className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRoutingPage;
|
||||
export default AddRoutingPage
|
||||
@@ -1,62 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import './routings.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface Routing {
|
||||
routing_name: string;
|
||||
routing_servers: string[];
|
||||
routing_description: string;
|
||||
client_key: string;
|
||||
routing_name: string
|
||||
routing_servers: string[]
|
||||
routing_description: string
|
||||
client_key: string
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const RoutingsPage = () => {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [routings, setRoutings] = useState<Routing[]>([]);
|
||||
const [allRoutings, setAllRoutings] = useState<Routing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('routing_name');
|
||||
const router = useRouter()
|
||||
const [routings, setRoutings] = useState<Routing[]>([])
|
||||
const [allRoutings, setAllRoutings] = useState<Routing[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState('routing_name')
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoutings();
|
||||
}, []);
|
||||
fetchRoutings()
|
||||
}, [])
|
||||
|
||||
const fetchRoutings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`http://localhost:3002/platform/routing/all?page=1&page_size=10`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
@@ -64,28 +36,28 @@ const RoutingsPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load routings');
|
||||
throw new Error('Failed to load routings')
|
||||
}
|
||||
const data = await response.json();
|
||||
const routingList = Array.isArray(data) ? data : (data.routings || data.response?.routings || []);
|
||||
setAllRoutings(routingList);
|
||||
setRoutings(routingList);
|
||||
const data = await response.json()
|
||||
const routingList = Array.isArray(data) ? data : (data.routings || data.response?.routings || [])
|
||||
setAllRoutings(routingList)
|
||||
setRoutings(routingList)
|
||||
} catch (err) {
|
||||
setError('Failed to load routings. Please try again later.');
|
||||
setRoutings([]);
|
||||
setAllRoutings([]);
|
||||
setError('Failed to load routings. Please try again later.')
|
||||
setRoutings([])
|
||||
setAllRoutings([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!searchTerm.trim()) {
|
||||
setRoutings(allRoutings);
|
||||
return;
|
||||
setRoutings(allRoutings)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredRoutings = allRoutings.filter(routing =>
|
||||
@@ -93,150 +65,200 @@ const RoutingsPage = () => {
|
||||
routing.routing_description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
routing.client_key.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
routing.routing_servers.some(server => server.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
setRoutings(filteredRoutings);
|
||||
};
|
||||
)
|
||||
setRoutings(filteredRoutings)
|
||||
}
|
||||
|
||||
const handleSort = (sortField: string) => {
|
||||
setSortBy(sortField);
|
||||
setSortBy(sortField)
|
||||
const sortedRoutings = [...routings].sort((a, b) => {
|
||||
if (sortField === 'routing_name') {
|
||||
return a.routing_name.localeCompare(b.routing_name);
|
||||
return a.routing_name.localeCompare(b.routing_name)
|
||||
} else if (sortField === 'client_key') {
|
||||
return a.client_key.localeCompare(b.client_key);
|
||||
return a.client_key.localeCompare(b.client_key)
|
||||
} else if (sortField === 'servers') {
|
||||
return a.routing_servers.length - b.routing_servers.length;
|
||||
return a.routing_servers.length - b.routing_servers.length
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setRoutings(sortedRoutings);
|
||||
};
|
||||
return 0
|
||||
})
|
||||
setRoutings(sortedRoutings)
|
||||
}
|
||||
|
||||
const handleRoutingClick = (routing: Routing) => {
|
||||
// Store routing data in sessionStorage for the detail page
|
||||
sessionStorage.setItem('selectedRouting', JSON.stringify(routing));
|
||||
router.push(`/routings/${routing.client_key}`);
|
||||
};
|
||||
sessionStorage.setItem('selectedRouting', JSON.stringify(routing))
|
||||
router.push(`/routings/${routing.client_key}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="routings-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="routings-root">
|
||||
<aside className="routings-sidebar">
|
||||
<div className="routings-sidebar-title">Menu</div>
|
||||
<ul className="routings-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`routings-sidebar-item${idx === 2 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`routings-sidebar-item${idx === 2 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="routings-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="routings-main">
|
||||
<div className="routings-header-row">
|
||||
<h1 className="routings-title">Routings</h1>
|
||||
<button className="refresh-button" onClick={fetchRoutings}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Routings</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage API routing configurations and load balancing
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/routings/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Routing
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="routings-controls">
|
||||
<form className="routings-search-box" onSubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className="routings-search-input"
|
||||
placeholder="Search routings..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="routings-search-btn">Search</button>
|
||||
<Link href="/routings/add" className="routings-add-btn" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Add Routing
|
||||
</Link>
|
||||
{/* Search and Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search routings by name, description, or servers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="routings-sort-group">
|
||||
<button
|
||||
className={`routings-sort-btn ${sortBy === 'routing_name' ? 'active' : ''}`}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSort('routing_name')}
|
||||
className={`btn ${sortBy === 'routing_name' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Name
|
||||
</button>
|
||||
<button
|
||||
className={`routings-sort-btn ${sortBy === 'client_key' ? 'active' : ''}`}
|
||||
<button
|
||||
onClick={() => handleSort('client_key')}
|
||||
className={`btn ${sortBy === 'client_key' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Key
|
||||
</button>
|
||||
<button
|
||||
className={`routings-sort-btn ${sortBy === 'servers' ? 'active' : ''}`}
|
||||
<button
|
||||
onClick={() => handleSort('servers')}
|
||||
className={`btn ${sortBy === 'servers' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Servers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading routings...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading routings...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="routings-table-panel">
|
||||
<table className="routings-table">
|
||||
</div>
|
||||
) : (
|
||||
/* Routings Table */
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Client Key</th>
|
||||
<th>Description</th>
|
||||
<th>Servers</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routings.map((routing) => (
|
||||
<tr
|
||||
key={routing.client_key}
|
||||
key={routing.client_key}
|
||||
onClick={() => handleRoutingClick(routing)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors"
|
||||
>
|
||||
<td>{routing.routing_name}</td>
|
||||
<td>{routing.client_key}</td>
|
||||
<td>{routing.routing_description || 'No description'}</td>
|
||||
<td>
|
||||
<span className="routings-servers-badge">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center mr-3">
|
||||
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{routing.routing_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code className="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded font-mono">
|
||||
{routing.client_key}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
|
||||
{routing.routing_description || 'No description'}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-primary">
|
||||
{routing.routing_servers.length} server{routing.routing_servers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoutingsPage;
|
||||
{/* Empty State */}
|
||||
{routings.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-16 w-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No routings found</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{searchTerm ? 'Try adjusting your search terms.' : 'Get started by creating your first routing configuration.'}
|
||||
</p>
|
||||
<Link href="/routings/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Routing
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoutingsPage
|
||||
|
||||
@@ -1,338 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import './security.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
status: 'active' | 'revoked';
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
created: string
|
||||
lastUsed: string
|
||||
status: 'active' | 'revoked'
|
||||
}
|
||||
|
||||
interface RateLimit {
|
||||
id: string;
|
||||
path: string;
|
||||
limit: number;
|
||||
window: string;
|
||||
status: 'active' | 'disabled';
|
||||
id: string
|
||||
path: string
|
||||
limit: number
|
||||
window: string
|
||||
status: 'active' | 'disabled'
|
||||
}
|
||||
|
||||
interface IpWhitelist {
|
||||
id: string;
|
||||
ip: string;
|
||||
description: string;
|
||||
created: string;
|
||||
status: 'active' | 'disabled';
|
||||
id: string
|
||||
ip: string
|
||||
description: string
|
||||
created: string
|
||||
status: 'active' | 'disabled'
|
||||
}
|
||||
|
||||
interface SecurityPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'jwt' | 'oauth2' | 'api-key' | 'ip-whitelist';
|
||||
status: 'active' | 'disabled';
|
||||
createdAt: string;
|
||||
id: string
|
||||
name: string
|
||||
type: 'jwt' | 'oauth2' | 'api-key' | 'ip-whitelist'
|
||||
status: 'active' | 'disabled'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const SecurityPage = () => {
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('api-keys');
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [rateLimits, setRateLimits] = useState<RateLimit[]>([]);
|
||||
const [ipWhitelist, setIpWhitelist] = useState<IpWhitelist[]>([]);
|
||||
const [securityPolicies, setSecurityPolicies] = useState<SecurityPolicy[]>([]);
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('api-keys')
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
const [rateLimits, setRateLimits] = useState<RateLimit[]>([])
|
||||
const [ipWhitelist, setIpWhitelist] = useState<IpWhitelist[]>([])
|
||||
const [securityPolicies, setSecurityPolicies] = useState<SecurityPolicy[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
fetchSecurityData();
|
||||
}, []);
|
||||
fetchSecurityData()
|
||||
}, [])
|
||||
|
||||
const fetchSecurityData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [keysRes, limitsRes, whitelistRes, policiesRes] = await Promise.all([
|
||||
fetch('/api/security/keys'),
|
||||
fetch('/api/security/rate-limits'),
|
||||
fetch('/api/security/ip-whitelist'),
|
||||
fetch('/api/security/policies')
|
||||
]);
|
||||
|
||||
if (!keysRes.ok || !limitsRes.ok || !whitelistRes.ok || !policiesRes.ok) {
|
||||
throw new Error('Failed to load security data');
|
||||
}
|
||||
|
||||
const [keys, limits, whitelist, policies] = await Promise.all([
|
||||
keysRes.json(),
|
||||
limitsRes.json(),
|
||||
whitelistRes.json(),
|
||||
policiesRes.json()
|
||||
]);
|
||||
|
||||
setApiKeys(keys);
|
||||
setRateLimits(limits);
|
||||
setIpWhitelist(whitelist);
|
||||
setSecurityPolicies(policies);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Mock data for demonstration
|
||||
setApiKeys([
|
||||
{ id: '1', name: 'Production API Key', key: 'sk_prod_123456789', created: '2024-01-15', lastUsed: '2024-01-20', status: 'active' },
|
||||
{ id: '2', name: 'Development API Key', key: 'sk_dev_987654321', created: '2024-01-10', lastUsed: '2024-01-19', status: 'active' }
|
||||
])
|
||||
|
||||
setRateLimits([
|
||||
{ id: '1', path: '/api/v1/*', limit: 1000, window: '1 hour', status: 'active' },
|
||||
{ id: '2', path: '/api/v1/auth/*', limit: 100, window: '1 hour', status: 'active' }
|
||||
])
|
||||
|
||||
setIpWhitelist([
|
||||
{ id: '1', ip: '192.168.1.100', description: 'Office Network', created: '2024-01-15', status: 'active' },
|
||||
{ id: '2', ip: '10.0.0.50', description: 'VPN Server', created: '2024-01-10', status: 'active' }
|
||||
])
|
||||
|
||||
setSecurityPolicies([
|
||||
{ id: '1', name: 'JWT Authentication', type: 'jwt', status: 'active', createdAt: '2024-01-15' },
|
||||
{ id: '2', name: 'API Key Authentication', type: 'api-key', status: 'active', createdAt: '2024-01-10' }
|
||||
])
|
||||
} catch (err) {
|
||||
setError('Failed to load security data. Please try again later.');
|
||||
setError('Failed to load security data. Please try again later.')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleCreateApiKey = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/security/keys', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: 'New API Key' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create API key');
|
||||
}
|
||||
|
||||
const newKey = await response.json();
|
||||
setApiKeys(prev => [...prev, newKey]);
|
||||
setSuccess('API key created successfully');
|
||||
setSuccess('API key created successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError('Failed to create API key. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setError('Failed to create API key. Please try again.')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRevokeApiKey = async (keyId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`/api/security/keys/${keyId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to revoke API key');
|
||||
}
|
||||
|
||||
setApiKeys(prev => prev.map(key =>
|
||||
key.id === keyId ? { ...key, status: 'revoked' } : key
|
||||
));
|
||||
setSuccess('API key revoked successfully');
|
||||
key.id === keyId ? { ...key, status: 'revoked' as const } : key
|
||||
))
|
||||
setSuccess('API key revoked successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError('Failed to revoke API key. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setError('Failed to revoke API key. Please try again.')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleAddRateLimit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/security/rate-limits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: '/api/*',
|
||||
limit: 100,
|
||||
window: '1m',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add rate limit');
|
||||
}
|
||||
|
||||
const newLimit = await response.json();
|
||||
setRateLimits(prev => [...prev, newLimit]);
|
||||
setSuccess('Rate limit added successfully');
|
||||
setSuccess('Rate limit added successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError('Failed to add rate limit. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setError('Failed to add rate limit. Please try again.')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleAddIpWhitelist = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/security/ip-whitelist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ip: '192.168.1.1',
|
||||
description: 'New IP Address',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add IP to whitelist');
|
||||
}
|
||||
|
||||
const newIp = await response.json();
|
||||
setIpWhitelist(prev => [...prev, newIp]);
|
||||
setSuccess('IP added to whitelist successfully');
|
||||
setSuccess('IP address added to whitelist successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError('Failed to add IP to whitelist. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setError('Failed to add IP to whitelist. Please try again.')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleCreateSecurityPolicy = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/security/policies', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'New Security Policy',
|
||||
type: 'api-key',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create security policy');
|
||||
}
|
||||
|
||||
const newPolicy = await response.json();
|
||||
setSecurityPolicies(prev => [...prev, newPolicy]);
|
||||
setSuccess('Security policy created successfully');
|
||||
setSuccess('Security policy created successfully!')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError('Failed to create security policy. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setError('Failed to create security policy. Please try again.')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'api-keys', label: 'API Keys', icon: '🔑' },
|
||||
{ id: 'rate-limits', label: 'Rate Limits', icon: '⏱️' },
|
||||
{ id: 'ip-whitelist', label: 'IP Whitelist', icon: '🌐' },
|
||||
{ id: 'policies', label: 'Security Policies', icon: '🛡️' }
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="security-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="security-root">
|
||||
<aside className="security-sidebar">
|
||||
<div className="security-sidebar-title">Menu</div>
|
||||
<ul className="security-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`security-sidebar-item${idx === 8 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`security-sidebar-item${idx === 8 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="security-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="security-main">
|
||||
<div className="security-header-row">
|
||||
<h1 className="security-title">Security</h1>
|
||||
<button className="refresh-button" onClick={fetchSecurityData}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Security</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage API keys, rate limits, and security policies
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="rounded-lg bg-success-50 border border-success-200 p-4 dark:bg-success-900/20 dark:border-success-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-success-400 dark:text-success-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-success-700 dark:text-success-300">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="success-container">
|
||||
<div className="success-message">
|
||||
{success}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading security data...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading security data...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="security-content">
|
||||
<div className="security-tabs">
|
||||
<button
|
||||
className={`security-tab ${activeTab === 'api-keys' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('api-keys')}
|
||||
>
|
||||
API Keys
|
||||
</button>
|
||||
<button
|
||||
className={`security-tab ${activeTab === 'rate-limits' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('rate-limits')}
|
||||
>
|
||||
Gateway Rate Limits
|
||||
</button>
|
||||
<button
|
||||
className={`security-tab ${activeTab === 'ip-whitelist' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('ip-whitelist')}
|
||||
>
|
||||
IP Control
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<div className="card">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="security-panel">
|
||||
<div className="p-6">
|
||||
{/* API Keys Tab */}
|
||||
{activeTab === 'api-keys' && (
|
||||
<div className="security-section">
|
||||
<div className="security-section-header">
|
||||
<h2>API Keys</h2>
|
||||
<button className="security-add-btn" onClick={handleCreateApiKey}>
|
||||
Create New Key
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">API Keys</h3>
|
||||
<button onClick={handleCreateApiKey} className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
<div className="security-table">
|
||||
<table>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -340,29 +233,40 @@ const SecurityPage = () => {
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apiKeys.map(key => (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.key}</td>
|
||||
<td>{new Date(key.created).toLocaleDateString()}</td>
|
||||
<td>{key.lastUsed ? new Date(key.lastUsed).toLocaleDateString() : 'Never'}</td>
|
||||
{apiKeys.map((key) => (
|
||||
<tr key={key.id} className="hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors">
|
||||
<td>
|
||||
<span className={`status-badge ${key.status}`}>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{key.name}</p>
|
||||
</td>
|
||||
<td>
|
||||
<code className="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded font-mono">
|
||||
{key.key}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{key.created}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{key.lastUsed}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${key.status === 'active' ? 'badge-success' : 'badge-error'}`}>
|
||||
{key.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="security-revoke-btn"
|
||||
onClick={() => handleRevokeApiKey(key.id)}
|
||||
disabled={key.status === 'revoked'}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
{key.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleRevokeApiKey(key.id)}
|
||||
className="btn btn-ghost btn-sm text-error-600 hover:text-error-700"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -372,39 +276,44 @@ const SecurityPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate Limits Tab */}
|
||||
{activeTab === 'rate-limits' && (
|
||||
<div className="security-section">
|
||||
<div className="security-section-header">
|
||||
<h2>Rate Limits</h2>
|
||||
<button className="security-add-btn" onClick={handleAddRateLimit}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Rate Limits</h3>
|
||||
<button onClick={handleAddRateLimit} className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Rate Limit
|
||||
</button>
|
||||
</div>
|
||||
<div className="security-table">
|
||||
<table>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Limit</th>
|
||||
<th>Window</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rateLimits.map(limit => (
|
||||
<tr key={limit.id}>
|
||||
<td>{limit.path}</td>
|
||||
<td>{limit.limit}</td>
|
||||
<td>{limit.window}</td>
|
||||
{rateLimits.map((limit) => (
|
||||
<tr key={limit.id} className="hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors">
|
||||
<td>
|
||||
<span className={`status-badge ${limit.status}`}>
|
||||
{limit.status}
|
||||
</span>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{limit.path}</p>
|
||||
</td>
|
||||
<td>
|
||||
<button className="security-edit-btn">Edit</button>
|
||||
<button className="security-delete-btn">Delete</button>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{limit.limit}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{limit.window}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${limit.status === 'active' ? 'badge-success' : 'badge-gray'}`}>
|
||||
{limit.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -414,39 +323,91 @@ const SecurityPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP Whitelist Tab */}
|
||||
{activeTab === 'ip-whitelist' && (
|
||||
<div className="security-section">
|
||||
<div className="security-section-header">
|
||||
<h2>IP Whitelist</h2>
|
||||
<button className="security-add-btn" onClick={handleAddIpWhitelist}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">IP Whitelist</h3>
|
||||
<button onClick={handleAddIpWhitelist} className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add IP Address
|
||||
</button>
|
||||
</div>
|
||||
<div className="security-table">
|
||||
<table>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Description</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ipWhitelist.map(ip => (
|
||||
<tr key={ip.id}>
|
||||
<td>{ip.ip}</td>
|
||||
<td>{ip.description}</td>
|
||||
<td>{new Date(ip.created).toLocaleDateString()}</td>
|
||||
{ipWhitelist.map((ip) => (
|
||||
<tr key={ip.id} className="hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors">
|
||||
<td>
|
||||
<span className={`status-badge ${ip.status}`}>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{ip.ip}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{ip.description}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{ip.created}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${ip.status === 'active' ? 'badge-success' : 'badge-gray'}`}>
|
||||
{ip.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Policies Tab */}
|
||||
{activeTab === 'policies' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Security Policies</h3>
|
||||
<button onClick={handleCreateSecurityPolicy} className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Policy
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{securityPolicies.map((policy) => (
|
||||
<tr key={policy.id} className="hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors">
|
||||
<td>
|
||||
<button className="security-edit-btn">Edit</button>
|
||||
<button className="security-delete-btn">Delete</button>
|
||||
<p className="font-medium text-gray-900 dark:text-white">{policy.name}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-primary">{policy.type}</span>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{policy.createdAt}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${policy.status === 'active' ? 'badge-success' : 'badge-gray'}`}>
|
||||
{policy.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -457,11 +418,11 @@ const SecurityPage = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default SecurityPage;
|
||||
export default SecurityPage
|
||||
@@ -1,45 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import './settings.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface UserSettings {
|
||||
username: string;
|
||||
email: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
originalUsername: string;
|
||||
originalEmail: string;
|
||||
username: string
|
||||
email: string
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
originalUsername: string
|
||||
originalEmail: string
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [settings, setSettings] = useState<UserSettings>({
|
||||
username: '',
|
||||
email: '',
|
||||
@@ -48,19 +25,16 @@ const SettingsPage = () => {
|
||||
confirmPassword: '',
|
||||
originalUsername: '',
|
||||
originalEmail: '',
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
fetchUserSettings();
|
||||
}, []);
|
||||
fetchUserSettings()
|
||||
}, [])
|
||||
|
||||
const fetchUserSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch('http://localhost:3002/platform/user/me', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
@@ -68,240 +42,288 @@ const SettingsPage = () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load user settings');
|
||||
throw new Error('Failed to load user settings')
|
||||
}
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
originalUsername: data.username,
|
||||
originalEmail: data.email,
|
||||
}));
|
||||
}))
|
||||
} catch (err) {
|
||||
setError('Failed to load user settings. Please try again later.');
|
||||
setError('Failed to load user settings. Please try again later.')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
const { name, value } = e.target
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
if (settings.newPassword !== settings.confirmPassword) {
|
||||
setError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Update user info if username or email changed
|
||||
if (settings.username !== settings.originalUsername || settings.email !== settings.originalEmail) {
|
||||
const userInfoResponse = await fetch(`http://localhost:3002/platform/user/${settings.originalUsername}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: settings.username,
|
||||
email: settings.email
|
||||
})
|
||||
});
|
||||
|
||||
if (!userInfoResponse.ok) {
|
||||
const errorData = await userInfoResponse.json();
|
||||
throw new Error(errorData.error_message || 'Failed to update user info');
|
||||
}
|
||||
|
||||
// Update original values after successful update
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
originalUsername: settings.username,
|
||||
originalEmail: settings.email
|
||||
}));
|
||||
const updateData: any = {}
|
||||
|
||||
if (settings.username !== settings.originalUsername) {
|
||||
updateData.username = settings.username
|
||||
}
|
||||
|
||||
// Update password if new password is provided
|
||||
|
||||
if (settings.email !== settings.originalEmail) {
|
||||
updateData.email = settings.email
|
||||
}
|
||||
|
||||
if (settings.newPassword) {
|
||||
if (settings.newPassword !== settings.confirmPassword) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordResponse = await fetch(`http://localhost:3002/platform/user/${settings.username}/update-password`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_password: settings.currentPassword,
|
||||
new_password: settings.newPassword
|
||||
})
|
||||
});
|
||||
|
||||
if (!passwordResponse.ok) {
|
||||
const errorData = await passwordResponse.json();
|
||||
throw new Error(errorData.error_message || 'Failed to update password');
|
||||
}
|
||||
updateData.current_password = settings.currentPassword
|
||||
updateData.new_password = settings.newPassword
|
||||
}
|
||||
|
||||
setSuccess('Settings updated successfully');
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
setError('No changes to save')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch('http://localhost:3002/platform/user/update', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to update settings')
|
||||
}
|
||||
|
||||
setSuccess('Settings updated successfully!')
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
originalUsername: settings.username,
|
||||
originalEmail: settings.email,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
}));
|
||||
confirmPassword: ''
|
||||
}))
|
||||
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update settings. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Failed to update settings. Please try again.')
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="settings-root">
|
||||
<aside className="settings-sidebar">
|
||||
<div className="settings-sidebar-title">Menu</div>
|
||||
<ul className="settings-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`settings-sidebar-item${idx === 9 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`settings-sidebar-item${idx === 9 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="settings-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="settings-main">
|
||||
<div className="settings-header-row">
|
||||
<h1 className="settings-title">Settings</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Settings</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage your account settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="rounded-lg bg-success-50 border border-success-200 p-4 dark:bg-success-900/20 dark:border-success-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-success-400 dark:text-success-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-success-700 dark:text-success-300">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="success-container">
|
||||
<div className="success-message">
|
||||
{success}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading settings...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-panel">
|
||||
<form onSubmit={handleSubmit} className="settings-form">
|
||||
<div className="settings-section">
|
||||
<h2>Account Information</h2>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={settings.username}
|
||||
onChange={handleInputChange}
|
||||
className="settings-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={settings.email}
|
||||
onChange={handleInputChange}
|
||||
className="settings-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h2>Change Password</h2>
|
||||
<div className="form-group">
|
||||
<label htmlFor="currentPassword">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
value={settings.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
className="settings-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="newPassword">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={settings.newPassword}
|
||||
onChange={handleInputChange}
|
||||
className="settings-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={settings.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className="settings-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button type="submit" className="settings-save-btn">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
<div className="update-warning">Warning: Any settings changes will log you out.</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
/* Settings Form */
|
||||
<div className="card max-w-2xl">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Account Settings</h3>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Profile Information */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white">Profile Information</h4>
|
||||
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={settings.username}
|
||||
onChange={handleInputChange}
|
||||
className="input"
|
||||
placeholder="Enter your username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={settings.email}
|
||||
onChange={handleInputChange}
|
||||
className="input"
|
||||
placeholder="Enter your email address"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Change */}
|
||||
<div className="space-y-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white">Change Password</h4>
|
||||
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
value={settings.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
className="input"
|
||||
placeholder="Enter your current password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={settings.newPassword}
|
||||
onChange={handleInputChange}
|
||||
className="input"
|
||||
placeholder="Enter your new password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={settings.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className="input"
|
||||
placeholder="Confirm your new password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Saving...
|
||||
</div>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
username: prev.originalUsername,
|
||||
email: prev.originalEmail,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}))
|
||||
setError(null)
|
||||
}}
|
||||
className="btn btn-secondary flex-1"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
||||
export default SettingsPage
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import '../users.css';
|
||||
import './add-user.css';
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface CreateUserData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: string;
|
||||
groups: string[];
|
||||
rate_limit_duration?: number;
|
||||
rate_limit_duration_type?: string;
|
||||
throttle_duration?: number;
|
||||
throttle_duration_type?: string;
|
||||
throttle_wait_duration?: number;
|
||||
throttle_wait_duration_type?: string;
|
||||
throttle_queue_limit?: number | null;
|
||||
custom_attributes: Record<string, string>;
|
||||
active: boolean;
|
||||
ui_access: boolean;
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
role: string
|
||||
groups: string[]
|
||||
rate_limit_duration?: number
|
||||
rate_limit_duration_type?: string
|
||||
throttle_duration?: number
|
||||
throttle_duration_type?: string
|
||||
throttle_wait_duration?: number
|
||||
throttle_wait_duration_type?: string
|
||||
throttle_queue_limit?: number | null
|
||||
custom_attributes: Record<string, string>
|
||||
active: boolean
|
||||
ui_access: boolean
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const AddUserPage = () => {
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState<CreateUserData>({
|
||||
username: '',
|
||||
email: '',
|
||||
@@ -56,49 +34,49 @@ const AddUserPage = () => {
|
||||
custom_attributes: {},
|
||||
active: true,
|
||||
ui_access: false
|
||||
});
|
||||
const [newGroup, setNewGroup] = useState('');
|
||||
const [newCustomAttribute, setNewCustomAttribute] = useState({ key: '', value: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [passwordStrength, setPasswordStrength] = useState({ score: 0, message: '' });
|
||||
})
|
||||
const [newGroup, setNewGroup] = useState('')
|
||||
const [newCustomAttribute, setNewCustomAttribute] = useState({ key: '', value: '' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [passwordStrength, setPasswordStrength] = useState({ score: 0, message: '' })
|
||||
|
||||
const handleInputChange = (field: keyof CreateUserData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Check password strength when password changes
|
||||
if (field === 'password') {
|
||||
checkPasswordStrength(value);
|
||||
checkPasswordStrength(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const checkPasswordStrength = (password: string) => {
|
||||
let score = 0;
|
||||
let message = '';
|
||||
let score = 0
|
||||
let message = ''
|
||||
|
||||
if (password.length >= 16) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[a-z]/.test(password)) score++;
|
||||
if (/\d/.test(password)) score++;
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score++;
|
||||
if (password.length >= 16) score++
|
||||
if (/[A-Z]/.test(password)) score++
|
||||
if (/[a-z]/.test(password)) score++
|
||||
if (/\d/.test(password)) score++
|
||||
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score++
|
||||
|
||||
if (score < 3) message = 'Weak - Password must include at least 16 characters, one uppercase letter, one lowercase letter, one digit, and one special character';
|
||||
else if (score < 5) message = 'Medium - Add more complexity';
|
||||
else message = 'Strong - Password meets security requirements';
|
||||
if (score < 3) message = 'Weak - Password must include at least 16 characters, one uppercase letter, one lowercase letter, one digit, and one special character'
|
||||
else if (score < 5) message = 'Medium - Add more complexity'
|
||||
else message = 'Strong - Password meets security requirements'
|
||||
|
||||
setPasswordStrength({ score, message });
|
||||
};
|
||||
setPasswordStrength({ score, message })
|
||||
}
|
||||
|
||||
const addGroup = () => {
|
||||
if (newGroup.trim()) {
|
||||
setFormData(prev => ({ ...prev, groups: [...prev.groups, newGroup.trim()] }));
|
||||
setNewGroup('');
|
||||
setFormData(prev => ({ ...prev, groups: [...prev.groups, newGroup.trim()] }))
|
||||
setNewGroup('')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const removeGroup = (index: number) => {
|
||||
setFormData(prev => ({ ...prev, groups: prev.groups.filter((_, i) => i !== index) }));
|
||||
};
|
||||
setFormData(prev => ({ ...prev, groups: prev.groups.filter((_, i) => i !== index) }))
|
||||
}
|
||||
|
||||
const addCustomAttribute = () => {
|
||||
if (newCustomAttribute.key && newCustomAttribute.value) {
|
||||
@@ -108,34 +86,34 @@ const AddUserPage = () => {
|
||||
...prev.custom_attributes,
|
||||
[newCustomAttribute.key]: newCustomAttribute.value
|
||||
}
|
||||
}));
|
||||
setNewCustomAttribute({ key: '', value: '' });
|
||||
}))
|
||||
setNewCustomAttribute({ key: '', value: '' })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const removeCustomAttribute = (key: string) => {
|
||||
const newCustomAttributes = { ...formData.custom_attributes };
|
||||
delete newCustomAttributes[key];
|
||||
setFormData(prev => ({ ...prev, custom_attributes: newCustomAttributes }));
|
||||
};
|
||||
const newCustomAttributes = { ...formData.custom_attributes }
|
||||
delete newCustomAttributes[key]
|
||||
setFormData(prev => ({ ...prev, custom_attributes: newCustomAttributes }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
|
||||
// Validate required fields
|
||||
if (!formData.username || !formData.email || !formData.password || !formData.role) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
setError('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordStrength.score < 5) {
|
||||
setError('Password does not meet security requirements');
|
||||
return;
|
||||
setError('Password does not meet security requirements')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('http://localhost:3002/platform/user/', {
|
||||
method: 'POST',
|
||||
@@ -146,336 +124,398 @@ const AddUserPage = () => {
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error_message || 'Failed to create user');
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error_message || 'Failed to create user')
|
||||
}
|
||||
|
||||
// Redirect to users list after successful creation
|
||||
router.push('/users');
|
||||
router.push('/users')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="users-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="users-root">
|
||||
<aside className="users-sidebar">
|
||||
<div className="users-sidebar-title">Menu</div>
|
||||
<ul className="users-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`users-sidebar-item${idx === 3 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`users-sidebar-item${idx === 3 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="users-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="users-main">
|
||||
<div className="users-header-row">
|
||||
<div className="add-user-header">
|
||||
<button className="back-button" onClick={handleBack}>
|
||||
<span className="back-arrow">←</span>
|
||||
Back to Users
|
||||
</button>
|
||||
<h1 className="users-title">Add New User</h1>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Add New User</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Create a new user account with permissions and settings
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/users" className="btn btn-secondary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Form */}
|
||||
<div className="card max-w-4xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Basic Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
className="input"
|
||||
placeholder="Enter username"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="input"
|
||||
placeholder="Enter email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="input"
|
||||
placeholder="Enter password (min 16 chars)"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
minLength={16}
|
||||
maxLength={50}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
{formData.password && (
|
||||
<div className={`mt-2 text-sm ${passwordStrength.score < 5 ? 'text-error-600 dark:text-error-400' : 'text-success-600 dark:text-success-400'}`}>
|
||||
{passwordStrength.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Role *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="role"
|
||||
className="input"
|
||||
placeholder="Enter role"
|
||||
value={formData.role}
|
||||
onChange={(e) => handleInputChange('role', e.target.value)}
|
||||
minLength={2}
|
||||
maxLength={50}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="input"
|
||||
value={formData.active ? 'true' : 'false'}
|
||||
onChange={(e) => handleInputChange('active', e.target.value === 'true')}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ui_access" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
UI Access
|
||||
</label>
|
||||
<select
|
||||
id="ui_access"
|
||||
className="input"
|
||||
value={formData.ui_access ? 'true' : 'false'}
|
||||
onChange={(e) => handleInputChange('ui_access', e.target.value === 'true')}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-user-content">
|
||||
<form onSubmit={handleSubmit} className="add-user-form">
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Basic Information</h2>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Username *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
placeholder="Enter username"
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email *</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-input"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="Enter email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
placeholder="Enter password (min 16 chars)"
|
||||
minLength={16}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
{formData.password && (
|
||||
<div className={`password-strength ${passwordStrength.score < 5 ? 'weak' : 'strong'}`}>
|
||||
{passwordStrength.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Role *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.role}
|
||||
onChange={(e) => handleInputChange('role', e.target.value)}
|
||||
placeholder="Enter role"
|
||||
minLength={2}
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.active ? 'true' : 'false'}
|
||||
onChange={(e) => handleInputChange('active', e.target.value === 'true')}
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">UI Access</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.ui_access ? 'true' : 'false'}
|
||||
onChange={(e) => handleInputChange('ui_access', e.target.value === 'true')}
|
||||
>
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Groups</h2>
|
||||
<div className="groups-container">
|
||||
<div className="groups-list">
|
||||
{formData.groups.map((group, index) => (
|
||||
<span key={index} className="group-tag">
|
||||
{group}
|
||||
<button
|
||||
type="button"
|
||||
className="remove-group-btn"
|
||||
onClick={() => removeGroup(index)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="add-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Enter group name"
|
||||
value={newGroup}
|
||||
onChange={(e) => setNewGroup(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addGroup())}
|
||||
/>
|
||||
<button type="button" className="add-button" onClick={addGroup}>
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Rate Limiting</h2>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Rate Limit Duration</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.rate_limit_duration || ''}
|
||||
onChange={(e) => handleInputChange('rate_limit_duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min="0"
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Rate Limit Type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.rate_limit_duration_type || ''}
|
||||
onChange={(e) => handleInputChange('rate_limit_duration_type', e.target.value)}
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Throttling</h2>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Throttle Duration</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.throttle_duration || ''}
|
||||
onChange={(e) => handleInputChange('throttle_duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min="0"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Throttle Type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.throttle_duration_type || ''}
|
||||
onChange={(e) => handleInputChange('throttle_duration_type', e.target.value)}
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Wait Duration</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.throttle_wait_duration || ''}
|
||||
onChange={(e) => handleInputChange('throttle_wait_duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min="0"
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Wait Type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.throttle_wait_duration_type || ''}
|
||||
onChange={(e) => handleInputChange('throttle_wait_duration_type', e.target.value)}
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Queue Limit</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.throttle_queue_limit || ''}
|
||||
onChange={(e) => handleInputChange('throttle_queue_limit', e.target.value ? parseInt(e.target.value) : null)}
|
||||
min="0"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2 className="section-title">Custom Attributes</h2>
|
||||
<div className="custom-attributes-container">
|
||||
{Object.entries(formData.custom_attributes).map(([key, value]) => (
|
||||
<div key={key} className="custom-attribute-item">
|
||||
<span className="custom-attribute-key">{key}:</span>
|
||||
<span className="custom-attribute-value">{value}</span>
|
||||
{/* Groups */}
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Groups</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.groups.map((group, index) => (
|
||||
<div key={index} className="flex items-center gap-2 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 px-3 py-1 rounded-full">
|
||||
<span className="text-sm">{group}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="remove-button"
|
||||
onClick={() => removeCustomAttribute(key)}
|
||||
onClick={() => removeGroup(index)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
disabled={loading}
|
||||
>
|
||||
×
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="add-custom-attribute">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Key"
|
||||
value={newCustomAttribute.key}
|
||||
onChange={(e) => setNewCustomAttribute(prev => ({ ...prev, key: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Value"
|
||||
value={newCustomAttribute.value}
|
||||
onChange={(e) => setNewCustomAttribute(prev => ({ ...prev, value: e.target.value }))}
|
||||
/>
|
||||
<button type="button" className="add-button" onClick={addCustomAttribute}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder="Enter group name"
|
||||
value={newGroup}
|
||||
onChange={(e) => setNewGroup(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addGroup())}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={addGroup} disabled={loading}>
|
||||
Add Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="cancel-button" onClick={handleBack}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="save-button" disabled={loading}>
|
||||
{loading ? 'Creating User...' : 'Create User'}
|
||||
</button>
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Rate Limiting</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="rate_limit_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rate Limit Duration
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rate_limit_duration"
|
||||
className="input"
|
||||
value={formData.rate_limit_duration || ''}
|
||||
onChange={(e) => handleInputChange('rate_limit_duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min="0"
|
||||
placeholder="100"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rate_limit_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rate Limit Type
|
||||
</label>
|
||||
<select
|
||||
id="rate_limit_duration_type"
|
||||
className="input"
|
||||
value={formData.rate_limit_duration_type || ''}
|
||||
onChange={(e) => handleInputChange('rate_limit_duration_type', e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
</div>
|
||||
|
||||
export default AddUserPage;
|
||||
{/* Throttling */}
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Throttling</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="throttle_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Throttle Duration
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="throttle_duration"
|
||||
className="input"
|
||||
value={formData.throttle_duration || ''}
|
||||
onChange={(e) => handleInputChange('throttle_duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min="0"
|
||||
placeholder="10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="throttle_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Throttle Type
|
||||
</label>
|
||||
<select
|
||||
id="throttle_duration_type"
|
||||
className="input"
|
||||
value={formData.throttle_duration_type || ''}
|
||||
onChange={(e) => handleInputChange('throttle_duration_type', e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="throttle_wait_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Wait Duration
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="throttle_wait_duration"
|
||||
className="input"
|
||||
value={formData.throttle_wait_duration || ''}
|
||||
onChange={(e) => handleInputChange('throttle_wait_duration', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min="0"
|
||||
placeholder="5"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="throttle_wait_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Wait Type
|
||||
</label>
|
||||
<select
|
||||
id="throttle_wait_duration_type"
|
||||
className="input"
|
||||
value={formData.throttle_wait_duration_type || ''}
|
||||
onChange={(e) => handleInputChange('throttle_wait_duration_type', e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="throttle_queue_limit" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Queue Limit
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="throttle_queue_limit"
|
||||
className="input"
|
||||
value={formData.throttle_queue_limit || ''}
|
||||
onChange={(e) => handleInputChange('throttle_queue_limit', e.target.value ? parseInt(e.target.value) : null)}
|
||||
min="0"
|
||||
placeholder="10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Attributes */}
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Custom Attributes</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(formData.custom_attributes).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-200 px-3 py-1 rounded-full">
|
||||
<span className="text-sm">{key}: {value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomAttribute(key)}
|
||||
className="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-200"
|
||||
disabled={loading}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Key"
|
||||
value={newCustomAttribute.key}
|
||||
onChange={(e) => setNewCustomAttribute(prev => ({ ...prev, key: e.target.value }))}
|
||||
disabled={loading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Value"
|
||||
value={newCustomAttribute.value}
|
||||
onChange={(e) => setNewCustomAttribute(prev => ({ ...prev, value: e.target.value }))}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={addCustomAttribute} disabled={loading}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex gap-4 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="submit" disabled={loading} className="btn btn-primary flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="spinner mr-2"></div>
|
||||
Creating User...
|
||||
</div>
|
||||
) : (
|
||||
'Create User'
|
||||
)}
|
||||
</button>
|
||||
<Link href="/users" className="btn btn-secondary flex-1">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddUserPage
|
||||
+205
-176
@@ -1,258 +1,287 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import './users.css';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
groups: string[];
|
||||
rate_limit_duration: number;
|
||||
rate_limit_duration_type: string;
|
||||
throttle_duration: number;
|
||||
throttle_duration_type: string;
|
||||
throttle_wait_duration: number;
|
||||
throttle_wait_duration_type: string;
|
||||
throttle_queue_limit: number | null;
|
||||
custom_attributes: Record<string, string>;
|
||||
active: boolean;
|
||||
username: string
|
||||
email: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
last_login?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard' },
|
||||
{ label: 'APIs', href: '/apis' },
|
||||
{ label: 'Routings', href: '/routings' },
|
||||
{ label: 'Users', href: '/users' },
|
||||
{ label: 'Groups', href: '/groups' },
|
||||
{ label: 'Roles', href: '/roles' },
|
||||
{ label: 'Monitor', href: '/monitor' },
|
||||
{ label: 'Logs', href: '/logging' },
|
||||
{ label: 'Security', href: '/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
const router = useRouter();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('username');
|
||||
|
||||
useEffect(() => {}, []);
|
||||
const router = useRouter()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortBy, setSortBy] = useState('username')
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`http://localhost:3002/platform/user/all?page=1&page_size=10`, {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`http://localhost:3002/platform/user/all`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
|
||||
}
|
||||
});
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load users');
|
||||
throw new Error('Failed to load users')
|
||||
}
|
||||
const data = await response.json();
|
||||
setAllUsers(data.users);
|
||||
setUsers(data.users);
|
||||
const data = await response.json()
|
||||
const userList = Array.isArray(data) ? data : (data.users || data.response?.users || [])
|
||||
setAllUsers(userList)
|
||||
setUsers(userList)
|
||||
} catch (err) {
|
||||
setError('Failed to load users. Please try again later.');
|
||||
setUsers([]);
|
||||
setAllUsers([]);
|
||||
setError('Failed to load users. Please try again later.')
|
||||
setUsers([])
|
||||
setAllUsers([])
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!searchTerm.trim()) {
|
||||
setUsers(allUsers);
|
||||
return;
|
||||
setUsers(allUsers)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredUsers = allUsers.filter(user =>
|
||||
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.role.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.groups.some(group => group.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
setUsers(filteredUsers);
|
||||
};
|
||||
(user.roles || []).some(role => role.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
setUsers(filteredUsers)
|
||||
}
|
||||
|
||||
const handleSort = (sortField: string) => {
|
||||
setSortBy(sortField);
|
||||
setSortBy(sortField)
|
||||
const sortedUsers = [...users].sort((a, b) => {
|
||||
if (sortField === 'username') {
|
||||
return a.username.localeCompare(b.username);
|
||||
} else if (sortField === 'role') {
|
||||
return a.role.localeCompare(b.role);
|
||||
return a.username.localeCompare(b.username)
|
||||
} else if (sortField === 'email') {
|
||||
return a.email.localeCompare(b.email)
|
||||
} else if (sortField === 'status') {
|
||||
return a.active ? 1 : -1;
|
||||
return a.is_active === b.is_active ? 0 : a.is_active ? -1 : 1
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
setUsers(sortedUsers);
|
||||
};
|
||||
return 0
|
||||
})
|
||||
setUsers(sortedUsers)
|
||||
}
|
||||
|
||||
const handleUserClick = (user: User) => {
|
||||
// Store user data in sessionStorage for the detail page
|
||||
sessionStorage.setItem('selectedUser', JSON.stringify(user));
|
||||
router.push(`/users/${user.username}`);
|
||||
};
|
||||
sessionStorage.setItem('selectedUser', JSON.stringify(user))
|
||||
router.push(`/users/${user.username}`)
|
||||
}
|
||||
|
||||
const formatDuration = (duration: number | null | undefined, durationType: string | null | undefined) => {
|
||||
if (!duration || !durationType) return 'Not set';
|
||||
|
||||
const plural = duration !== 1 && (durationType.endsWith('minute') || durationType.endsWith('second') || durationType.endsWith('hour')) ? 's' : '';
|
||||
return `${duration} ${durationType}${plural}`;
|
||||
};
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="users-topbar">
|
||||
Doorman
|
||||
</div>
|
||||
<div className="users-root">
|
||||
<aside className="users-sidebar">
|
||||
<div className="users-sidebar-title">Menu</div>
|
||||
<ul className="users-sidebar-list">
|
||||
{menuItems.map((item, idx) => (
|
||||
item.href ? (
|
||||
<li key={item.label} className={`users-sidebar-item${idx === 3 ? ' active' : ''}`}>
|
||||
<Link href={item.href} style={{ color: 'inherit', textDecoration: 'none', display: 'block', width: '100%' }}>{item.label}</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.label} className={`users-sidebar-item${idx === 3 ? ' active' : ''}`}>{item.label}</li>
|
||||
)
|
||||
))}
|
||||
</ul>
|
||||
<button className="users-logout-btn" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="users-main">
|
||||
<div className="users-header-row">
|
||||
<h1 className="users-title">Users</h1>
|
||||
<button className="refresh-button" onClick={fetchUsers}>
|
||||
<span className="refresh-icon">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Users</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/users/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add User
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="users-controls">
|
||||
<form className="users-search-box" onSubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className="users-search-input"
|
||||
placeholder="Search users..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="users-search-btn">Search</button>
|
||||
<Link href="/users/add" className="users-add-btn" style={{ textDecoration: 'none', display: 'inline-block' }}>
|
||||
Add User
|
||||
</Link>
|
||||
{/* Search and Filters */}
|
||||
<div className="card">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search users by username, email, or role..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="users-sort-group">
|
||||
<button
|
||||
className={`users-sort-btn ${sortBy === 'username' ? 'active' : ''}`}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSort('username')}
|
||||
className={`btn ${sortBy === 'username' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Name
|
||||
Username
|
||||
</button>
|
||||
<button
|
||||
className={`users-sort-btn ${sortBy === 'role' ? 'active' : ''}`}
|
||||
onClick={() => handleSort('role')}
|
||||
<button
|
||||
onClick={() => handleSort('email')}
|
||||
className={`btn ${sortBy === 'email' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Role
|
||||
Email
|
||||
</button>
|
||||
<button
|
||||
className={`users-sort-btn ${sortBy === 'status' ? 'active' : ''}`}
|
||||
<button
|
||||
onClick={() => handleSort('status')}
|
||||
className={`btn ${sortBy === 'status' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-container">
|
||||
<div className="error-message">
|
||||
{error}
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
|
||||
<div className="flex">
|
||||
<svg className="h-5 w-5 text-error-400 dark:text-error-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-spinner">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading users...</p>
|
||||
{/* Loading State */}
|
||||
{loading ? (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="spinner mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="users-table-panel">
|
||||
<table className="users-table">
|
||||
</div>
|
||||
) : (
|
||||
/* Users Table */
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Groups</th>
|
||||
<th>Rate Limit</th>
|
||||
<th>Throttle</th>
|
||||
<th>Roles</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.username}
|
||||
key={user.username}
|
||||
onClick={() => handleUserClick(user)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
|
||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-surfaceHover transition-colors"
|
||||
>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.role}</td>
|
||||
<td>{user.groups.length > 0 ? user.groups[0] : ''}</td>
|
||||
<td>{formatDuration(user.rate_limit_duration, user.rate_limit_duration_type)}</td>
|
||||
<td>{formatDuration(user.throttle_duration, user.throttle_duration_type)}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${user.active ? 'active' : 'inactive'}`}>
|
||||
{user.active ? 'Active' : 'Inactive'}
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center text-white font-medium">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{user.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{user.email}</p>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(user.roles || []).slice(0, 2).map((role, index) => (
|
||||
<span key={index} className="badge badge-primary text-xs">
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
{(user.roles || []).length > 2 && (
|
||||
<span className="badge badge-gray text-xs">
|
||||
+{(user.roles || []).length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${user.is_active ? 'badge-success' : 'badge-error'}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(user.last_login || '')}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost btn-sm">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
{/* Empty State */}
|
||||
{users.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-16 w-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">No users found</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{searchTerm ? 'Try adjusting your search terms.' : 'Get started by creating your first user account.'}
|
||||
</p>
|
||||
<Link href="/users/add" className="btn btn-primary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add User
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersPage
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: '📊' },
|
||||
{ label: 'APIs', href: '/apis', icon: '🔌' },
|
||||
{ label: 'Routings', href: '/routings', icon: '🛣️' },
|
||||
{ label: 'Users', href: '/users', icon: '👥' },
|
||||
{ label: 'Groups', href: '/groups', icon: '👨👩👧👦' },
|
||||
{ label: 'Roles', href: '/roles', icon: '🔐' },
|
||||
{ label: 'Monitor', href: '/monitor', icon: '📈' },
|
||||
{ label: 'Logs', href: '/logging', icon: '📝' },
|
||||
{ label: 'Security', href: '/security', icon: '🛡️' },
|
||||
{ label: 'Settings', href: '/settings', icon: '⚙️' },
|
||||
]
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme') || 'light'
|
||||
setTheme(savedTheme)
|
||||
document.documentElement.classList.toggle('dark', savedTheme === 'dark')
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light'
|
||||
setTheme(newTheme)
|
||||
localStorage.setItem('theme', newTheme)
|
||||
document.documentElement.classList.toggle('dark', newTheme === 'dark')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
document.cookie = 'access_token_cookie=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-dark-bg">
|
||||
{/* Topbar */}
|
||||
<header className="topbar">
|
||||
<div className="flex h-full items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="lg:hidden rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-dark-textSecondary dark:hover:bg-dark-surfaceHover"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">D</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold gradient-text">Doorman</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:text-dark-textSecondary dark:hover:bg-dark-surfaceHover transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="h-8 w-px bg-gray-200 dark:bg-dark-border" />
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-ghost text-sm"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`sidebar ${sidebarOpen ? 'translate-x-0' : ''}`}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 space-y-1 p-4">
|
||||
<nav className="space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`sidebar-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<span className="mr-3 text-lg">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="main-content">
|
||||
<div className="p-4 lg:p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
@@ -6,11 +6,120 @@ export default {
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
// Light mode colors
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
950: '#030712',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
// Dark mode specific colors
|
||||
dark: {
|
||||
bg: '#0a0a0a',
|
||||
surface: '#1a1a1a',
|
||||
surfaceHover: '#2a2a2a',
|
||||
border: '#333333',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#a1a1aa',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'slide-down': 'slideDown 0.3s ease-out',
|
||||
'scale-in': 'scaleIn 0.2s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { transform: 'scale(0.95)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||
'medium': '0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'strong': '0 10px 40px -10px rgba(0, 0, 0, 0.15), 0 2px 10px -2px rgba(0, 0, 0, 0.05)',
|
||||
'glow': '0 0 20px rgba(59, 130, 246, 0.15)',
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user