Add comprehensive backend features and mobile UI improvements
Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import type { ReactElement } from 'react';
|
||||
import { SiteConfigProvider } from './contexts/SiteConfigContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
@@ -7,17 +7,22 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { SidebarProvider } from './contexts/SidebarContext';
|
||||
import { ViewModeProvider } from './contexts/ViewModeContext';
|
||||
import { ModulesProvider } from './contexts/ModulesContext';
|
||||
import { NotificationsProvider } from './contexts/NotificationsContext';
|
||||
import MainLayout from './components/MainLayout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Feature1 from './pages/Feature1';
|
||||
import Feature2 from './pages/Feature2';
|
||||
import Feature3 from './pages/Feature3';
|
||||
import Notifications from './pages/Notifications';
|
||||
import APIKeys from './pages/APIKeys';
|
||||
import AdminPanel from './pages/AdminPanel';
|
||||
import Sources from './pages/admin/Sources';
|
||||
import Features from './pages/admin/Features';
|
||||
import Settings from './pages/Settings';
|
||||
import ThemeSettings from './pages/admin/ThemeSettings';
|
||||
import Analytics from './pages/admin/Analytics';
|
||||
import AuditLogs from './pages/admin/AuditLogs';
|
||||
import './App.css';
|
||||
|
||||
function PrivateRoute({ children }: { children: ReactElement }) {
|
||||
@@ -46,8 +51,13 @@ function AdminRoute({ children }: { children: ReactElement }) {
|
||||
|
||||
function AppRoutes() {
|
||||
const { user, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
// Don't show loading screen on login page - it would unmount the Login component
|
||||
// and lose the 2FA temp token state during the login flow
|
||||
const isLoginPage = location.pathname === '/login';
|
||||
|
||||
if (isLoading && !isLoginPage) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
@@ -60,6 +70,8 @@ function AppRoutes() {
|
||||
<Route path="/feature1" element={<Feature1 />} />
|
||||
<Route path="/feature2" element={<Feature2 />} />
|
||||
<Route path="/feature3" element={<Feature3 />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
<Route path="/api-keys" element={<APIKeys />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
<Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
|
||||
@@ -67,6 +79,8 @@ function AppRoutes() {
|
||||
<Route path="/admin/sources" element={<AdminRoute><Sources /></AdminRoute>} />
|
||||
<Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} />
|
||||
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
|
||||
<Route path="/admin/analytics" element={<AdminRoute><Analytics /></AdminRoute>} />
|
||||
<Route path="/admin/audit" element={<AdminRoute><AuditLogs /></AdminRoute>} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
|
||||
@@ -84,9 +98,11 @@ function App() {
|
||||
<LanguageProvider>
|
||||
<ModulesProvider>
|
||||
<ViewModeProvider>
|
||||
<SidebarProvider>
|
||||
<AppRoutes />
|
||||
</SidebarProvider>
|
||||
<NotificationsProvider>
|
||||
<SidebarProvider>
|
||||
<AppRoutes />
|
||||
</SidebarProvider>
|
||||
</NotificationsProvider>
|
||||
</ViewModeProvider>
|
||||
</ModulesProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -31,6 +31,11 @@ export const authAPI = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
verify2fa: async (data: { temp_token: string; code: string }): Promise<Token> => {
|
||||
const response = await api.post<Token>('/auth/verify-2fa', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (data: RegisterRequest): Promise<User> => {
|
||||
const response = await api.post<User>('/auth/register', data);
|
||||
return response.data;
|
||||
@@ -40,6 +45,38 @@ export const authAPI = {
|
||||
const response = await api.get<User>('/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post('/auth/logout');
|
||||
},
|
||||
};
|
||||
|
||||
// 2FA / TOTP endpoints
|
||||
export const twoFactorAPI = {
|
||||
getStatus: async (): Promise<{ enabled: boolean; has_backup_codes: boolean }> => {
|
||||
const response = await api.get<{ enabled: boolean; has_backup_codes: boolean }>('/2fa/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setup: async (): Promise<{ secret: string; uri: string; qr_code: string }> => {
|
||||
const response = await api.post<{ secret: string; uri: string; qr_code: string }>('/2fa/setup');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
verify: async (code: string): Promise<{ message: string; backup_codes: string[] }> => {
|
||||
const response = await api.post<{ message: string; backup_codes: string[] }>('/2fa/verify', { code });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
disable: async (data: { password: string; code: string }): Promise<{ message: string }> => {
|
||||
const response = await api.post<{ message: string }>('/2fa/disable', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
regenerateBackupCodes: async (code: string): Promise<{ backup_codes: string[] }> => {
|
||||
const response = await api.post<{ backup_codes: string[] }>('/2fa/regenerate-backup-codes', { code });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Settings endpoints
|
||||
@@ -64,8 +101,8 @@ export const settingsAPI = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateModules: async (data: Record<string, boolean | string[]>): Promise<Record<string, boolean | string[]>> => {
|
||||
const response = await api.put<Record<string, boolean | string[]>>('/settings/modules', data);
|
||||
updateModules: async (data: Record<string, boolean | string | string[]>): Promise<Record<string, boolean | string | string[]>> => {
|
||||
const response = await api.put<Record<string, boolean | string | string[]>>('/settings/modules', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -128,4 +165,173 @@ export const usersAPI = {
|
||||
},
|
||||
};
|
||||
|
||||
// API Keys endpoints
|
||||
export interface ApiKeyItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
scopes: string[] | null;
|
||||
is_active: boolean;
|
||||
last_used_at: string | null;
|
||||
last_used_ip: string | null;
|
||||
usage_count: number;
|
||||
expires_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const apiKeysAPI = {
|
||||
create: async (data: { name: string; scopes?: string[]; expires_at?: string | null }): Promise<ApiKeyItem & { key: string }> => {
|
||||
const response = await api.post<ApiKeyItem & { key: string }>('/api-keys', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
list: async (): Promise<{ items: ApiKeyItem[]; total: number }> => {
|
||||
const response = await api.get<{ items: ApiKeyItem[]; total: number }>('/api-keys');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
revoke: async (keyId: string): Promise<ApiKeyItem> => {
|
||||
const response = await api.post<ApiKeyItem>(`/api-keys/${keyId}/revoke`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (keyId: string): Promise<void> => {
|
||||
await api.delete(`/api-keys/${keyId}`);
|
||||
},
|
||||
};
|
||||
|
||||
// Sessions endpoints
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
device_name: string | null;
|
||||
device_type: string | null;
|
||||
browser: string | null;
|
||||
os: string | null;
|
||||
ip_address: string | null;
|
||||
location: string | null;
|
||||
is_active: boolean;
|
||||
is_current: boolean;
|
||||
created_at: string;
|
||||
last_active_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export const sessionsAPI = {
|
||||
list: async (): Promise<{ items: UserSession[]; total: number; active_count: number }> => {
|
||||
const response = await api.get<{ items: UserSession[]; total: number; active_count: number }>('/sessions');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
revoke: async (sessionId: string): Promise<UserSession> => {
|
||||
const response = await api.post<UserSession>(`/sessions/${sessionId}/revoke`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
revokeAllOther: async (): Promise<{ revoked: number }> => {
|
||||
const response = await api.post<{ revoked: number }>('/sessions/revoke-all');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Notifications endpoints
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
message: string | null;
|
||||
type: 'info' | 'success' | 'warning' | 'error' | 'system';
|
||||
link: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
}
|
||||
|
||||
export const notificationsAPI = {
|
||||
list: async (params?: { skip?: number; limit?: number; unread_only?: boolean }): Promise<{ items: NotificationItem[]; total: number; unread_count: number }> => {
|
||||
const response = await api.get<{ items: NotificationItem[]; total: number; unread_count: number }>('/notifications', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
unreadCount: async (): Promise<{ unread_count: number }> => {
|
||||
const response = await api.get<{ unread_count: number }>('/notifications/unread-count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsRead: async (notificationId: string): Promise<NotificationItem> => {
|
||||
const response = await api.post<NotificationItem>(`/notifications/${notificationId}/read`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAllAsRead: async (): Promise<{ marked_as_read: number }> => {
|
||||
const response = await api.post<{ marked_as_read: number }>('/notifications/read-all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (notificationId: string): Promise<void> => {
|
||||
await api.delete(`/notifications/${notificationId}`);
|
||||
},
|
||||
|
||||
deleteAllRead: async (): Promise<{ deleted: number }> => {
|
||||
const response = await api.delete<{ deleted: number }>('/notifications/read/all');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Analytics endpoints (admin)
|
||||
export type AnalyticsOverview = {
|
||||
users: { total: number; active: number; new_today: number; new_this_week: number; new_this_month: number };
|
||||
sessions: { active: number };
|
||||
api_keys: { total: number; active: number };
|
||||
security: { logins_24h: number; failed_logins_24h: number };
|
||||
notifications: { unread_total: number };
|
||||
generated_at: string;
|
||||
};
|
||||
|
||||
export const analyticsAPI = {
|
||||
overview: async (): Promise<AnalyticsOverview> => {
|
||||
const response = await api.get<AnalyticsOverview>('/analytics/overview');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
userActivity: async (days: number = 7): Promise<{ daily_stats: { date: string; active_users: number; new_users: number }[] }> => {
|
||||
const response = await api.get<{ daily_stats: { date: string; active_users: number; new_users: number }[] }>('/analytics/users/activity', {
|
||||
params: { days },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
actionsBreakdown: async (hours: number = 24): Promise<{ period_hours: number; actions: { action: string; count: number }[] }> => {
|
||||
const response = await api.get<{ period_hours: number; actions: { action: string; count: number }[] }>('/analytics/actions/breakdown', {
|
||||
params: { hours },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Audit logs endpoints (admin)
|
||||
export type AuditLogItem = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
action: string;
|
||||
resource_type: string | null;
|
||||
resource_id: string | null;
|
||||
details: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export const auditAPI = {
|
||||
list: async (params?: Record<string, unknown>): Promise<{ items: AuditLogItem[]; total: number; page: number; page_size: number; total_pages: number }> => {
|
||||
const response = await api.get<{ items: AuditLogItem[]; total: number; page: number; page_size: number; total_pages: number }>('/audit', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from '../contexts/AuthContext';
|
||||
import { useModules } from '../contexts/ModulesContext';
|
||||
import { useSiteConfig } from '../contexts/SiteConfigContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useNotifications } from '../contexts/NotificationsContext';
|
||||
import { appModules } from '../modules';
|
||||
import UserMenu from './UserMenu';
|
||||
import '../styles/Sidebar.css';
|
||||
@@ -27,7 +28,8 @@ export default function Sidebar() {
|
||||
} = useSidebar();
|
||||
const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode();
|
||||
const { user } = useAuth();
|
||||
const { isModuleEnabled, isModuleEnabledForUser, moduleOrder, hasInitialized: modulesInitialized } = useModules();
|
||||
const { isModuleEnabled, isModuleEnabledForUser, moduleOrder, moduleStates, hasInitialized: modulesInitialized } = useModules();
|
||||
const { unreadCount } = useNotifications();
|
||||
|
||||
// When admin is in "user mode", show only user-permitted modules
|
||||
// Otherwise, show all globally enabled modules (admin view)
|
||||
@@ -38,6 +40,8 @@ export default function Sidebar() {
|
||||
.find((cat) => cat.id === 'main')
|
||||
?.modules.filter((m) => {
|
||||
if (!m.enabled) return false;
|
||||
// Dashboard is always shown
|
||||
if (m.id === 'dashboard') return true;
|
||||
if (shouldUseUserPermissions) {
|
||||
return isModuleEnabledForUser(m.id, user?.permissions, user?.is_superuser || false);
|
||||
}
|
||||
@@ -45,7 +49,7 @@ export default function Sidebar() {
|
||||
}) || []);
|
||||
|
||||
// Sort modules based on moduleOrder (dashboard always first, then ordered features)
|
||||
const mainModules = [...mainModulesFiltered].sort((a, b) => {
|
||||
const sortedModules = [...mainModulesFiltered].sort((a, b) => {
|
||||
// Dashboard always comes first
|
||||
if (a.id === 'dashboard') return -1;
|
||||
if (b.id === 'dashboard') return 1;
|
||||
@@ -63,6 +67,19 @@ export default function Sidebar() {
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Split modules by position (top = main nav, bottom = above footer)
|
||||
const topModules = sortedModules.filter(m => {
|
||||
if (m.id === 'dashboard') return true; // Dashboard always at top
|
||||
const state = moduleStates[m.id as keyof typeof moduleStates];
|
||||
return !state || state.position === 'top';
|
||||
});
|
||||
|
||||
const bottomModules = sortedModules.filter(m => {
|
||||
if (m.id === 'dashboard') return false; // Dashboard never at bottom
|
||||
const state = moduleStates[m.id as keyof typeof moduleStates];
|
||||
return state && state.position === 'bottom';
|
||||
});
|
||||
|
||||
const handleCollapseClick = () => {
|
||||
if (isMobileOpen) {
|
||||
closeMobileMenu();
|
||||
@@ -222,7 +239,7 @@ export default function Sidebar() {
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="nav-section">
|
||||
{mainModules.map((module) => (
|
||||
{topModules.map((module) => (
|
||||
<NavLink
|
||||
key={module.id}
|
||||
to={module.path}
|
||||
@@ -233,12 +250,36 @@ export default function Sidebar() {
|
||||
>
|
||||
<span className="nav-icon material-symbols-outlined">{module.icon}</span>
|
||||
<span className="nav-label">{t.sidebar[module.id as keyof typeof t.sidebar]}</span>
|
||||
{module.id === 'notifications' && unreadCount > 0 && (
|
||||
<span className="nav-badge" aria-label={`${unreadCount} unread notifications`}>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{bottomModules.map((module) => (
|
||||
<NavLink
|
||||
key={module.id}
|
||||
to={module.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={(e) => handleNavClick(e, module.path)}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(t.sidebar[module.id as keyof typeof t.sidebar], e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="nav-icon material-symbols-outlined">{module.icon}</span>
|
||||
<span className="nav-label">{t.sidebar[module.id as keyof typeof t.sidebar]}</span>
|
||||
{module.id === 'notifications' && unreadCount > 0 && (
|
||||
<span className="nav-badge" aria-label={`${unreadCount} unread notifications`}>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{user?.is_superuser && isUserModeEnabled && (
|
||||
<button
|
||||
className={`view-mode-toggle ${viewMode === 'user' ? 'user-mode' : 'admin-mode'}`}
|
||||
|
||||
@@ -98,6 +98,22 @@ export default function UserMenu({ onOpenChange }: { onOpenChange?: (isOpen: boo
|
||||
<span className="material-symbols-outlined">brush</span>
|
||||
<span>{t.theme.title}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/analytics"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/admin/analytics')}
|
||||
>
|
||||
<span className="material-symbols-outlined">analytics</span>
|
||||
<span>{t.analyticsPage.title}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/audit"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/admin/audit')}
|
||||
>
|
||||
<span className="material-symbols-outlined">history</span>
|
||||
<span>{t.auditPage.title}</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="user-menu-divider" />
|
||||
</>
|
||||
@@ -112,6 +128,14 @@ export default function UserMenu({ onOpenChange }: { onOpenChange?: (isOpen: boo
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
<span>{t.sidebar.settings}</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/api-keys"
|
||||
className="user-menu-item"
|
||||
onClick={(e) => handleNavClick(e, '/api-keys')}
|
||||
>
|
||||
<span className="material-symbols-outlined">vpn_key</span>
|
||||
<span>{t.apiKeysPage.title}</span>
|
||||
</NavLink>
|
||||
{themeInitialized && showDarkModeToggle && darkModeLocation === 'user_menu' && (
|
||||
<button onClick={toggleTheme} className="user-menu-item">
|
||||
<span className="material-symbols-outlined">
|
||||
|
||||
@@ -30,15 +30,52 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
console.log('[AuthContext] login() called');
|
||||
try {
|
||||
const response = await authAPI.login({ username, password });
|
||||
console.log('[AuthContext] API response:', response);
|
||||
if (response.requires_2fa) {
|
||||
console.log('[AuthContext] 2FA required, clearing auth state');
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
console.log('[AuthContext] Returning requires_2fa: true');
|
||||
return { requires_2fa: true, temp_token: response.temp_token ?? null };
|
||||
}
|
||||
|
||||
if (!response.access_token) {
|
||||
throw new Error('Missing access token');
|
||||
}
|
||||
|
||||
localStorage.setItem('token', response.access_token);
|
||||
setToken(response.access_token);
|
||||
|
||||
const userData = await authAPI.getCurrentUser();
|
||||
setUser(userData);
|
||||
return { requires_2fa: false };
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verify2fa = async (tempToken: string, code: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authAPI.verify2fa({ temp_token: tempToken, code });
|
||||
if (!response.access_token) {
|
||||
throw new Error('Missing access token');
|
||||
}
|
||||
|
||||
localStorage.setItem('token', response.access_token);
|
||||
setToken(response.access_token);
|
||||
|
||||
const userData = await authAPI.getCurrentUser();
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
console.error('2FA verification failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -49,7 +86,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authAPI.register({ username, email, password });
|
||||
await login(username, password);
|
||||
const result = await login(username, password);
|
||||
if (result.requires_2fa) {
|
||||
throw new Error('2FA required after registration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
throw error;
|
||||
@@ -59,13 +99,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authAPI.logout().catch(() => {
|
||||
// ignore network/auth errors during client-side logout
|
||||
});
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, register, logout, isLoading }}>
|
||||
<AuthContext.Provider value={{ user, token, login, verify2fa, register, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -6,20 +6,24 @@ import type { UserPermissions } from '../types';
|
||||
|
||||
// User-facing modules that can be toggled
|
||||
export const TOGGLEABLE_MODULES = [
|
||||
{ id: 'feature1', icon: 'playlist_play', defaultEnabled: true },
|
||||
{ id: 'feature2', icon: 'download', defaultEnabled: true },
|
||||
{ id: 'feature3', icon: 'cast', defaultEnabled: true },
|
||||
{ id: 'feature1', icon: 'playlist_play', defaultEnabled: true, defaultPosition: 'top' as const },
|
||||
{ id: 'feature2', icon: 'download', defaultEnabled: true, defaultPosition: 'top' as const },
|
||||
{ id: 'feature3', icon: 'cast', defaultEnabled: true, defaultPosition: 'top' as const },
|
||||
{ id: 'search', icon: 'search', defaultEnabled: true, defaultPosition: 'bottom' as const },
|
||||
{ id: 'notifications', icon: 'notifications', defaultEnabled: true, defaultPosition: 'bottom' as const },
|
||||
] as const;
|
||||
|
||||
export type ModuleId = typeof TOGGLEABLE_MODULES[number]['id'];
|
||||
export type ModulePosition = 'top' | 'bottom';
|
||||
|
||||
export interface ModuleState {
|
||||
admin: boolean;
|
||||
user: boolean;
|
||||
position: ModulePosition;
|
||||
}
|
||||
|
||||
// Default order for modules
|
||||
const DEFAULT_MODULE_ORDER: string[] = ['feature1', 'feature2', 'feature3'];
|
||||
// Default order for modules (top modules, then bottom modules)
|
||||
const DEFAULT_MODULE_ORDER: string[] = ['feature1', 'feature2', 'feature3', 'search', 'notifications'];
|
||||
|
||||
interface ModulesContextType {
|
||||
moduleStates: Record<ModuleId, ModuleState>;
|
||||
@@ -27,6 +31,7 @@ interface ModulesContextType {
|
||||
isModuleEnabled: (moduleId: string) => boolean;
|
||||
isModuleEnabledForUser: (moduleId: string, userPermissions: UserPermissions | undefined, isSuperuser: boolean) => boolean;
|
||||
setModuleEnabled: (moduleId: ModuleId, type: 'admin' | 'user', enabled: boolean) => void;
|
||||
setModulePosition: (moduleId: ModuleId, position: ModulePosition) => void;
|
||||
setModuleOrder: (order: string[]) => void;
|
||||
saveModulesToBackend: () => Promise<void>;
|
||||
saveModuleOrder: (order: string[]) => Promise<void>;
|
||||
@@ -40,7 +45,7 @@ const ModulesContext = createContext<ModulesContextType | undefined>(undefined);
|
||||
const getDefaultStates = (): Record<ModuleId, ModuleState> => {
|
||||
const states: Record<string, ModuleState> = {};
|
||||
TOGGLEABLE_MODULES.forEach(m => {
|
||||
states[m.id] = { admin: m.defaultEnabled, user: m.defaultEnabled };
|
||||
states[m.id] = { admin: m.defaultEnabled, user: m.defaultEnabled, position: m.defaultPosition };
|
||||
});
|
||||
return states as Record<ModuleId, ModuleState>;
|
||||
};
|
||||
@@ -58,17 +63,18 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
|
||||
const settings = await settingsAPI.getModules();
|
||||
const newStates = { ...getDefaultStates() };
|
||||
|
||||
// Helper to safely parse boolean
|
||||
const parseBool = (val: any, defaultVal: boolean): boolean => {
|
||||
if (val === undefined || val === null) return defaultVal;
|
||||
if (val === true || val === 'true' || val === 1 || val === '1') return true;
|
||||
if (val === false || val === 'false' || val === 0 || val === '0') return false;
|
||||
return defaultVal;
|
||||
};
|
||||
|
||||
TOGGLEABLE_MODULES.forEach(m => {
|
||||
const adminKey = `module_${m.id}_admin_enabled`;
|
||||
const userKey = `module_${m.id}_user_enabled`;
|
||||
|
||||
// Helper to safely parse boolean
|
||||
const parseBool = (val: any, defaultVal: boolean): boolean => {
|
||||
if (val === undefined || val === null) return defaultVal;
|
||||
if (val === true || val === 'true' || val === 1 || val === '1') return true;
|
||||
if (val === false || val === 'false' || val === 0 || val === '0') return false;
|
||||
return defaultVal;
|
||||
};
|
||||
const positionKey = `module_${m.id}_position`;
|
||||
|
||||
// Check for new keys
|
||||
// If key exists in settings, use it (parsed). If not, use defaultEnabled.
|
||||
@@ -85,6 +91,13 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
|
||||
newStates[m.id].user = m.defaultEnabled;
|
||||
}
|
||||
|
||||
// Load position
|
||||
if (settings[positionKey] !== undefined) {
|
||||
newStates[m.id].position = settings[positionKey] === 'bottom' ? 'bottom' : 'top';
|
||||
} else {
|
||||
newStates[m.id].position = m.defaultPosition;
|
||||
}
|
||||
|
||||
// Fallback for backward compatibility (if old key exists)
|
||||
const oldKey = `module_${m.id}_enabled`;
|
||||
if (settings[oldKey] !== undefined && settings[adminKey] === undefined) {
|
||||
@@ -110,6 +123,12 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
|
||||
} else {
|
||||
order = DEFAULT_MODULE_ORDER;
|
||||
}
|
||||
// Ensure all toggleable modules are included (for newly added modules)
|
||||
const allModuleIds = TOGGLEABLE_MODULES.map(m => m.id);
|
||||
const missingModules = allModuleIds.filter(id => !order.includes(id));
|
||||
if (missingModules.length > 0) {
|
||||
order = [...order, ...missingModules];
|
||||
}
|
||||
setModuleOrderState(order);
|
||||
}
|
||||
|
||||
@@ -125,10 +144,11 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
|
||||
// Save module settings to backend
|
||||
const saveModulesToBackend = useCallback(async () => {
|
||||
try {
|
||||
const data: Record<string, boolean> = {};
|
||||
const data: Record<string, boolean | string> = {};
|
||||
TOGGLEABLE_MODULES.forEach(m => {
|
||||
data[`module_${m.id}_admin_enabled`] = moduleStates[m.id].admin;
|
||||
data[`module_${m.id}_user_enabled`] = moduleStates[m.id].user;
|
||||
data[`module_${m.id}_position`] = moduleStates[m.id].position;
|
||||
});
|
||||
await settingsAPI.updateModules(data);
|
||||
} catch (error) {
|
||||
@@ -221,6 +241,14 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setModulePosition = useCallback((moduleId: ModuleId, position: ModulePosition) => {
|
||||
setModuleStates(prev => {
|
||||
const newState = { ...prev };
|
||||
newState[moduleId] = { ...newState[moduleId], position };
|
||||
return newState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModulesContext.Provider
|
||||
value={{
|
||||
@@ -229,6 +257,7 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
|
||||
isModuleEnabled,
|
||||
isModuleEnabledForUser,
|
||||
setModuleEnabled,
|
||||
setModulePosition,
|
||||
setModuleOrder,
|
||||
saveModulesToBackend,
|
||||
saveModuleOrder,
|
||||
|
||||
53
frontend/src/contexts/NotificationsContext.tsx
Normal file
53
frontend/src/contexts/NotificationsContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { notificationsAPI } from '../api/client';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
type NotificationsContextType = {
|
||||
unreadCount: number;
|
||||
refreshUnreadCount: () => Promise<void>;
|
||||
setUnreadCount: (count: number) => void;
|
||||
};
|
||||
|
||||
const NotificationsContext = createContext<NotificationsContextType | undefined>(undefined);
|
||||
|
||||
export function NotificationsProvider({ children }: { children: ReactNode }) {
|
||||
const { token } = useAuth();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const refreshUnreadCount = useCallback(async () => {
|
||||
if (!token) {
|
||||
setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await notificationsAPI.unreadCount();
|
||||
setUnreadCount(data.unread_count || 0);
|
||||
} catch (error) {
|
||||
// ignore errors to avoid breaking navigation UI
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshUnreadCount();
|
||||
if (!token) return;
|
||||
|
||||
const interval = setInterval(refreshUnreadCount, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [token, refreshUnreadCount]);
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={{ unreadCount, refreshUnreadCount, setUnreadCount }}>
|
||||
{children}
|
||||
</NotificationsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const context = useContext(NotificationsContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotifications must be used within a NotificationsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"email": "Email",
|
||||
"twoFactorCode": "2FA code",
|
||||
"twoFactorPrompt": "Enter the 6-digit code from your authenticator app (or an 8-character backup code).",
|
||||
"verifyCode": "Verify",
|
||||
"backToLogin": "Back to login",
|
||||
"loginTitle": "Login",
|
||||
"registerTitle": "Register",
|
||||
"alreadyHaveAccount": "Already have an account? Login",
|
||||
@@ -27,6 +31,8 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"search": "Search",
|
||||
"notifications": "Notifications",
|
||||
"feature1": "Feature 1",
|
||||
"feature2": "Feature 2",
|
||||
"feature3": "Feature 3",
|
||||
@@ -59,7 +65,14 @@
|
||||
"configTab": "Configuration",
|
||||
"orderSection": "Sidebar Order",
|
||||
"orderDesc": "Drag to reorder features in the sidebar",
|
||||
"applyOrder": "Apply"
|
||||
"applyOrder": "Apply",
|
||||
"visibility": "Visibility",
|
||||
"topSection": "Main Section",
|
||||
"bottomSection": "Bottom Section",
|
||||
"moveToTop": "Move to top",
|
||||
"moveToBottom": "Move to bottom",
|
||||
"noModulesTop": "No modules in this section",
|
||||
"noModulesBottom": "No modules in this section"
|
||||
},
|
||||
"sourcesPage": {
|
||||
"title": "Sources",
|
||||
@@ -306,13 +319,103 @@
|
||||
"languageDesc": "Select your preferred language",
|
||||
"english": "English",
|
||||
"italian": "Italiano",
|
||||
"preferences": "Preferences",
|
||||
"security": "Security",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"twoFactorTitle": "Two-factor authentication (2FA)",
|
||||
"twoFactorDesc": "Protect your account with TOTP codes from an authenticator app.",
|
||||
"enable2fa": "Enable 2FA",
|
||||
"qrCodeAlt": "2FA QR code",
|
||||
"secret": "Secret",
|
||||
"verificationCode": "Verification code",
|
||||
"verifyEnable2fa": "Verify and enable",
|
||||
"backupCodes": "Backup codes",
|
||||
"backupCodesDesc": "Generate new backup codes (requires a valid code).",
|
||||
"backupCodesSaveHint": "Save these codes in a safe place. Each code can be used once to access your account if you lose your authenticator.",
|
||||
"regenerateBackupCodes": "Regenerate backup codes",
|
||||
"disable2fa": "Disable 2FA",
|
||||
"disable2faDesc": "Disable two-factor authentication (requires your password and a valid code).",
|
||||
"disable2faConfirm": "Disable",
|
||||
"sessionsTitle": "Active sessions",
|
||||
"sessionsDesc": "View and revoke sessions on other devices.",
|
||||
"revokeAllOtherSessions": "Revoke all other sessions",
|
||||
"revokeSession": "Revoke",
|
||||
"currentSession": "Current",
|
||||
"inactiveSession": "Inactive",
|
||||
"sessionsEmpty": "No sessions found",
|
||||
"unknownDevice": "Unknown device",
|
||||
"unknownBrowser": "Unknown browser",
|
||||
"unknownOs": "Unknown OS",
|
||||
"lastActive": "Last active",
|
||||
"comingSoon": "User settings will be available soon...",
|
||||
"placeholderTitle": "Settings"
|
||||
},
|
||||
"notificationsPage": {
|
||||
"title": "Notifications",
|
||||
"unreadOnly": "Unread only",
|
||||
"markAllRead": "Mark all as read",
|
||||
"deleteRead": "Delete read",
|
||||
"markRead": "Mark as read",
|
||||
"empty": "No notifications",
|
||||
"loadError": "Failed to load notifications"
|
||||
},
|
||||
"analyticsPage": {
|
||||
"title": "Analytics",
|
||||
"usersTotal": "Total users",
|
||||
"usersActive": "Active users",
|
||||
"usersNew": "New users",
|
||||
"sessionsActive": "Active sessions",
|
||||
"logins24h": "Logins (24h)",
|
||||
"failedLogins24h": "Failed (24h)",
|
||||
"notificationsUnread": "Unread notifications",
|
||||
"userActivity7d": "User activity (7d)",
|
||||
"actions24h": "Actions (24h)",
|
||||
"generatedAt": "Generated at"
|
||||
},
|
||||
"auditPage": {
|
||||
"title": "Audit Log",
|
||||
"username": "Username",
|
||||
"action": "Action",
|
||||
"resourceType": "Resource type",
|
||||
"anyStatus": "Any status",
|
||||
"statusSuccess": "Success",
|
||||
"statusFailure": "Failure",
|
||||
"statusPending": "Pending",
|
||||
"statusError": "Error",
|
||||
"empty": "No audit log entries",
|
||||
"time": "Time",
|
||||
"user": "User",
|
||||
"resource": "Resource",
|
||||
"status": "Status",
|
||||
"ip": "IP",
|
||||
"prev": "Prev",
|
||||
"next": "Next",
|
||||
"page": "Page"
|
||||
},
|
||||
"apiKeysPage": {
|
||||
"title": "API Keys",
|
||||
"createTitle": "Create API key",
|
||||
"createDesc": "Generate an API key for external API access. The key is shown only once.",
|
||||
"namePlaceholder": "Key name (e.g. CI, integration, script)",
|
||||
"createButton": "Create",
|
||||
"showOnce": "Shown once",
|
||||
"copy": "Copy",
|
||||
"listTitle": "Your keys",
|
||||
"empty": "No API keys yet",
|
||||
"name": "Name",
|
||||
"prefix": "Prefix",
|
||||
"status": "Status",
|
||||
"lastUsed": "Last used",
|
||||
"usage": "Usage",
|
||||
"actions": "Actions",
|
||||
"revoke": "Revoke",
|
||||
"revoked": "Revoked"
|
||||
},
|
||||
"feature1": {
|
||||
"title": "Feature 1",
|
||||
"subtitle": "Feature 1 management",
|
||||
"comingSoon": "Feature coming soon...",
|
||||
"management": "Feature 1 Management"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"username": "Nome utente",
|
||||
"password": "Password",
|
||||
"email": "Email",
|
||||
"twoFactorCode": "Codice 2FA",
|
||||
"twoFactorPrompt": "Inserisci il codice a 6 cifre della tua app (oppure un backup code da 8 caratteri).",
|
||||
"verifyCode": "Verifica",
|
||||
"backToLogin": "Torna al login",
|
||||
"loginTitle": "Accedi",
|
||||
"registerTitle": "Registrati",
|
||||
"alreadyHaveAccount": "Hai già un account? Accedi",
|
||||
@@ -27,6 +31,8 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"search": "Cerca",
|
||||
"notifications": "Notifiche",
|
||||
"feature1": "Funzione 1",
|
||||
"feature2": "Funzione 2",
|
||||
"feature3": "Funzione 3",
|
||||
@@ -59,7 +65,14 @@
|
||||
"configTab": "Configurazione",
|
||||
"orderSection": "Ordine nella Sidebar",
|
||||
"orderDesc": "Trascina per riordinare le funzioni nella barra laterale",
|
||||
"applyOrder": "Applica"
|
||||
"applyOrder": "Applica",
|
||||
"visibility": "Visibilità",
|
||||
"topSection": "Sezione Principale",
|
||||
"bottomSection": "Sezione Inferiore",
|
||||
"moveToTop": "Sposta in alto",
|
||||
"moveToBottom": "Sposta in basso",
|
||||
"noModulesTop": "Nessun modulo in questa sezione",
|
||||
"noModulesBottom": "Nessun modulo in questa sezione"
|
||||
},
|
||||
"sourcesPage": {
|
||||
"title": "Sorgenti",
|
||||
@@ -306,13 +319,103 @@
|
||||
"languageDesc": "Seleziona la tua lingua preferita",
|
||||
"english": "Inglese",
|
||||
"italian": "Italiano",
|
||||
"preferences": "Preferenze",
|
||||
"security": "Sicurezza",
|
||||
"enabled": "Attivo",
|
||||
"disabled": "Disattivo",
|
||||
"twoFactorTitle": "Autenticazione a due fattori (2FA)",
|
||||
"twoFactorDesc": "Proteggi il tuo account con codici TOTP da un'app di autenticazione.",
|
||||
"enable2fa": "Abilita 2FA",
|
||||
"qrCodeAlt": "QR code 2FA",
|
||||
"secret": "Segreto",
|
||||
"verificationCode": "Codice di verifica",
|
||||
"verifyEnable2fa": "Verifica e abilita",
|
||||
"backupCodes": "Backup codes",
|
||||
"backupCodesDesc": "Genera nuovi backup codes (richiede un codice valido).",
|
||||
"backupCodesSaveHint": "Salva questi codici in un luogo sicuro. Ogni codice può essere usato una sola volta per accedere se perdi l'app di autenticazione.",
|
||||
"regenerateBackupCodes": "Rigenera backup codes",
|
||||
"disable2fa": "Disabilita 2FA",
|
||||
"disable2faDesc": "Disabilita l'autenticazione a due fattori (richiede la password e un codice valido).",
|
||||
"disable2faConfirm": "Disabilita",
|
||||
"sessionsTitle": "Sessioni attive",
|
||||
"sessionsDesc": "Visualizza e termina le sessioni su altri dispositivi.",
|
||||
"revokeAllOtherSessions": "Termina tutte le altre sessioni",
|
||||
"revokeSession": "Termina",
|
||||
"currentSession": "Corrente",
|
||||
"inactiveSession": "Inattiva",
|
||||
"sessionsEmpty": "Nessuna sessione trovata",
|
||||
"unknownDevice": "Dispositivo sconosciuto",
|
||||
"unknownBrowser": "Browser sconosciuto",
|
||||
"unknownOs": "OS sconosciuto",
|
||||
"lastActive": "Ultima attività",
|
||||
"comingSoon": "Le impostazioni utente saranno disponibili a breve...",
|
||||
"placeholderTitle": "Impostazioni"
|
||||
},
|
||||
"notificationsPage": {
|
||||
"title": "Notifiche",
|
||||
"unreadOnly": "Solo non lette",
|
||||
"markAllRead": "Segna tutte come lette",
|
||||
"deleteRead": "Elimina lette",
|
||||
"markRead": "Segna come letta",
|
||||
"empty": "Nessuna notifica",
|
||||
"loadError": "Impossibile caricare le notifiche"
|
||||
},
|
||||
"analyticsPage": {
|
||||
"title": "Analytics",
|
||||
"usersTotal": "Utenti totali",
|
||||
"usersActive": "Utenti attivi",
|
||||
"usersNew": "Nuovi utenti",
|
||||
"sessionsActive": "Sessioni attive",
|
||||
"logins24h": "Login (24h)",
|
||||
"failedLogins24h": "Falliti (24h)",
|
||||
"notificationsUnread": "Notifiche non lette",
|
||||
"userActivity7d": "Attività utenti (7g)",
|
||||
"actions24h": "Azioni (24h)",
|
||||
"generatedAt": "Generato il"
|
||||
},
|
||||
"auditPage": {
|
||||
"title": "Audit Log",
|
||||
"username": "Nome utente",
|
||||
"action": "Azione",
|
||||
"resourceType": "Tipo risorsa",
|
||||
"anyStatus": "Qualsiasi stato",
|
||||
"statusSuccess": "Successo",
|
||||
"statusFailure": "Fallimento",
|
||||
"statusPending": "In attesa",
|
||||
"statusError": "Errore",
|
||||
"empty": "Nessuna voce di audit",
|
||||
"time": "Ora",
|
||||
"user": "Utente",
|
||||
"resource": "Risorsa",
|
||||
"status": "Stato",
|
||||
"ip": "IP",
|
||||
"prev": "Prec",
|
||||
"next": "Succ",
|
||||
"page": "Pagina"
|
||||
},
|
||||
"apiKeysPage": {
|
||||
"title": "API Keys",
|
||||
"createTitle": "Crea API key",
|
||||
"createDesc": "Genera una API key per accesso esterno alle API. La chiave viene mostrata una sola volta.",
|
||||
"namePlaceholder": "Nome chiave (es. CI, integrazione, script)",
|
||||
"createButton": "Crea",
|
||||
"showOnce": "Mostrata una volta",
|
||||
"copy": "Copia",
|
||||
"listTitle": "Le tue chiavi",
|
||||
"empty": "Nessuna API key",
|
||||
"name": "Nome",
|
||||
"prefix": "Prefisso",
|
||||
"status": "Stato",
|
||||
"lastUsed": "Ultimo uso",
|
||||
"usage": "Utilizzo",
|
||||
"actions": "Azioni",
|
||||
"revoke": "Revoca",
|
||||
"revoked": "Revocata"
|
||||
},
|
||||
"feature1": {
|
||||
"title": "Funzione 1",
|
||||
"subtitle": "Gestione Funzione 1",
|
||||
"comingSoon": "Funzionalità in arrivo...",
|
||||
"management": "Gestione Funzione 1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,24 @@ export const appModules: ModuleCategory[] = [
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
name: 'sidebar.search',
|
||||
icon: 'search',
|
||||
path: '/search',
|
||||
component: null,
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
name: 'sidebar.notifications',
|
||||
icon: 'notifications',
|
||||
path: '/notifications',
|
||||
component: null,
|
||||
enabled: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'feature1',
|
||||
name: 'sidebar.feature1',
|
||||
|
||||
189
frontend/src/pages/APIKeys.tsx
Normal file
189
frontend/src/pages/APIKeys.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { apiKeysAPI } from '../api/client';
|
||||
import type { ApiKeyItem } from '../api/client';
|
||||
import '../styles/APIKeys.css';
|
||||
|
||||
export default function APIKeys() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [items, setItems] = useState<ApiKeyItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await apiKeysAPI.list();
|
||||
setItems(data.items || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setCreatedKey(null);
|
||||
try {
|
||||
const created = await apiKeysAPI.create({ name: name.trim() });
|
||||
setCreatedKey(created.key);
|
||||
setName('');
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: string) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiKeysAPI.revoke(id);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiKeysAPI.delete(id);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
if (!createdKey) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content api-keys-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">vpn_key</span>
|
||||
<span className="page-title-text">{t.apiKeysPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="api-keys-section">
|
||||
<h3 className="section-title">{t.apiKeysPage.createTitle}</h3>
|
||||
<p className="api-keys-desc">{t.apiKeysPage.createDesc}</p>
|
||||
|
||||
<div className="api-keys-create-row">
|
||||
<input
|
||||
className="api-keys-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.apiKeysPage.namePlaceholder}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button className="btn-primary" onClick={create} disabled={busy || name.trim().length < 1}>
|
||||
{t.apiKeysPage.createButton}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createdKey && (
|
||||
<div className="api-keys-created">
|
||||
<div className="api-keys-created-header">
|
||||
<span className="badge badge-accent">{t.apiKeysPage.showOnce}</span>
|
||||
<button className="btn-link" onClick={copy}>{t.apiKeysPage.copy}</button>
|
||||
</div>
|
||||
<code className="api-keys-created-key">{createdKey}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="api-keys-section">
|
||||
<h3 className="section-title">{t.apiKeysPage.listTitle}</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="api-keys-empty">{t.apiKeysPage.empty}</div>
|
||||
) : (
|
||||
<div className="api-keys-table-card">
|
||||
<table className="api-keys-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t.apiKeysPage.name}</th>
|
||||
<th>{t.apiKeysPage.prefix}</th>
|
||||
<th>{t.apiKeysPage.status}</th>
|
||||
<th>{t.apiKeysPage.lastUsed}</th>
|
||||
<th>{t.apiKeysPage.usage}</th>
|
||||
<th>{t.apiKeysPage.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((k) => (
|
||||
<tr key={k.id}>
|
||||
<td>{k.name}</td>
|
||||
<td className="mono">{k.key_prefix}</td>
|
||||
<td>
|
||||
<span className={`badge ${k.is_active ? 'badge-success' : 'badge-muted'}`}>
|
||||
{k.is_active ? t.settings.enabled : t.settings.disabled}
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono">{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : '—'}</td>
|
||||
<td className="mono">{k.usage_count}</td>
|
||||
<td className="api-keys-actions">
|
||||
{k.is_active ? (
|
||||
<button className="btn-link" onClick={() => revoke(k.id)} disabled={busy}>
|
||||
{t.apiKeysPage.revoke}
|
||||
</button>
|
||||
) : (
|
||||
<span className="api-keys-muted">{t.apiKeysPage.revoked}</span>
|
||||
)}
|
||||
<button className="btn-link danger" onClick={() => deleteKey(k.id)} disabled={busy}>
|
||||
{t.common.delete}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,14 +12,21 @@ export default function Login() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [tempToken, setTempToken] = useState<string | null>(null);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
|
||||
const { login, register } = useAuth();
|
||||
const { login, verify2fa, register } = useAuth();
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { theme, toggleTheme, showDarkModeLogin, showLanguageLogin, showDarkModeToggle, showLanguageToggle } = useTheme();
|
||||
const { config } = useSiteConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Debug: track tempToken state changes
|
||||
useEffect(() => {
|
||||
console.log('[Login] tempToken changed:', tempToken ? 'SET' : 'NULL');
|
||||
}, [tempToken]);
|
||||
|
||||
// Check if registration is enabled
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -56,24 +63,47 @@ export default function Login() {
|
||||
try {
|
||||
if (isRegister) {
|
||||
await register(username, email, password);
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tempToken) {
|
||||
console.log('[Login] Verifying 2FA with tempToken');
|
||||
await verify2fa(tempToken, twoFactorCode);
|
||||
} else {
|
||||
await login(username, password);
|
||||
console.log('[Login] Calling login()');
|
||||
const result = await login(username, password);
|
||||
console.log('[Login] Login result:', result);
|
||||
if (result.requires_2fa) {
|
||||
console.log('[Login] 2FA required, temp_token:', result.temp_token);
|
||||
if (!result.temp_token) {
|
||||
throw new Error('missing-temp-token');
|
||||
}
|
||||
setTempToken(result.temp_token);
|
||||
setTwoFactorCode('');
|
||||
console.log('[Login] tempToken state set, returning');
|
||||
return;
|
||||
}
|
||||
}
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
if (err?.message === 'missing-temp-token') {
|
||||
setError(t.auth.authenticationFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = err.response?.data?.detail;
|
||||
if (Array.isArray(detail)) {
|
||||
// Handle Pydantic validation errors
|
||||
const messages = detail.map((e: any) => {
|
||||
const field = e.loc[e.loc.length - 1];
|
||||
return `${field}: ${e.msg}`;
|
||||
}).join('\n');
|
||||
const messages = detail
|
||||
.map((e: any) => {
|
||||
const field = e.loc[e.loc.length - 1];
|
||||
return `${field}: ${e.msg}`;
|
||||
})
|
||||
.join('\n');
|
||||
setError(messages);
|
||||
} else if (typeof detail === 'string') {
|
||||
// Handle standard HTTP exceptions
|
||||
setError(detail);
|
||||
} else {
|
||||
// Fallback
|
||||
setError(t.auth.authenticationFailed);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +131,7 @@ export default function Login() {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
disabled={tempToken !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -126,22 +157,55 @@ export default function Login() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={tempToken !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tempToken && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="twoFactorCode">{t.auth.twoFactorCode}</label>
|
||||
<input
|
||||
id="twoFactorCode"
|
||||
type="text"
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
maxLength={8}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="helper-text">{t.auth.twoFactorPrompt}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
{isRegister ? t.auth.register : t.auth.login}
|
||||
{isRegister ? t.auth.register : (tempToken ? t.auth.verifyCode : t.auth.login)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
{registrationEnabled === true && (
|
||||
{tempToken && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setTempToken(null);
|
||||
setTwoFactorCode('');
|
||||
setError('');
|
||||
}}
|
||||
className="btn-link"
|
||||
>
|
||||
{t.auth.backToLogin}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!tempToken && registrationEnabled === true && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRegister(!isRegister);
|
||||
setError('');
|
||||
setTempToken(null);
|
||||
setTwoFactorCode('');
|
||||
}}
|
||||
className="btn-link"
|
||||
>
|
||||
|
||||
166
frontend/src/pages/Notifications.tsx
Normal file
166
frontend/src/pages/Notifications.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { useNotifications } from '../contexts/NotificationsContext';
|
||||
import { notificationsAPI } from '../api/client';
|
||||
import type { NotificationItem } from '../api/client';
|
||||
import '../styles/Notifications.css';
|
||||
|
||||
export default function Notifications() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const { setUnreadCount, refreshUnreadCount } = useNotifications();
|
||||
|
||||
const [items, setItems] = useState<NotificationItem[]>([]);
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await notificationsAPI.list({ limit: 50, skip: 0, unread_only: unreadOnly });
|
||||
setItems(data.items);
|
||||
setUnreadCount(data.unread_count || 0);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.notificationsPage.loadError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [unreadOnly]);
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await notificationsAPI.markAllAsRead();
|
||||
await refreshUnreadCount();
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllRead = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await notificationsAPI.deleteAllRead();
|
||||
await refreshUnreadCount();
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const updated = await notificationsAPI.markAsRead(id);
|
||||
setItems((prev) => prev.map((n) => (n.id === id ? updated : n)));
|
||||
await refreshUnreadCount();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOne = async (id: string) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await notificationsAPI.delete(id);
|
||||
setItems((prev) => prev.filter((n) => n.id !== id));
|
||||
await refreshUnreadCount();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content notifications-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
<span className="page-title-text">{t.notificationsPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="notifications-toolbar">
|
||||
<div className="notifications-toggle">
|
||||
<span className="notifications-toggle-label">{t.notificationsPage.unreadOnly}</span>
|
||||
<label className={`toggle-modern ${(loading || busy) ? 'disabled' : ''}`}>
|
||||
<input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} disabled={loading || busy} />
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="notifications-actions">
|
||||
<button className="btn-primary" onClick={markAllAsRead} disabled={loading || busy}>
|
||||
{t.notificationsPage.markAllRead}
|
||||
</button>
|
||||
<button className="btn-danger" onClick={deleteAllRead} disabled={loading || busy}>
|
||||
{t.notificationsPage.deleteRead}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="notifications-empty">{t.notificationsPage.empty}</div>
|
||||
) : (
|
||||
<div className="notifications-list">
|
||||
{items.map((n) => (
|
||||
<div key={n.id} className={`notification-item ${n.is_read ? 'read' : 'unread'}`}>
|
||||
<div className="notification-main">
|
||||
<div className="notification-header">
|
||||
<div className="notification-title">
|
||||
<span className={`notification-type type-${n.type}`}>{n.type}</span>
|
||||
<span>{n.title}</span>
|
||||
</div>
|
||||
<div className="notification-date">{new Date(n.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
{n.message && <div className="notification-message">{n.message}</div>}
|
||||
</div>
|
||||
<div className="notification-actions">
|
||||
{!n.is_read && (
|
||||
<button className="btn-link" onClick={() => markAsRead(n.id)} disabled={busy}>
|
||||
{t.notificationsPage.markRead}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-link danger" onClick={() => deleteOne(n.id)} disabled={busy}>
|
||||
{t.common.delete}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { sessionsAPI, twoFactorAPI } from '../api/client';
|
||||
import type { UserSession } from '../api/client';
|
||||
import '../styles/SettingsPage.css';
|
||||
|
||||
export default function Settings() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [twoFactorStatus, setTwoFactorStatus] = useState<{ enabled: boolean; has_backup_codes: boolean } | null>(null);
|
||||
const [twoFactorLoading, setTwoFactorLoading] = useState(true);
|
||||
const [twoFactorBusy, setTwoFactorBusy] = useState(false);
|
||||
const [twoFactorError, setTwoFactorError] = useState('');
|
||||
|
||||
const [setupData, setSetupData] = useState<{ secret: string; uri: string; qr_code: string } | null>(null);
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
|
||||
const [regenerateCode, setRegenerateCode] = useState('');
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
|
||||
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
const [sessionsBusy, setSessionsBusy] = useState(false);
|
||||
const [sessionsError, setSessionsError] = useState('');
|
||||
|
||||
const loadTwoFactorStatus = async () => {
|
||||
try {
|
||||
const status = await twoFactorAPI.getStatus();
|
||||
setTwoFactorStatus(status);
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTwoFactorStatus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const loadSessions = async () => {
|
||||
setSessionsError('');
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data = await sessionsAPI.list();
|
||||
setSessions(data.items || []);
|
||||
} catch (err: any) {
|
||||
setSessionsError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const revokeSession = async (sessionId: string) => {
|
||||
setSessionsError('');
|
||||
setSessionsBusy(true);
|
||||
try {
|
||||
await sessionsAPI.revoke(sessionId);
|
||||
await loadSessions();
|
||||
} catch (err: any) {
|
||||
setSessionsError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setSessionsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeAllOtherSessions = async () => {
|
||||
setSessionsError('');
|
||||
setSessionsBusy(true);
|
||||
try {
|
||||
await sessionsAPI.revokeAllOther();
|
||||
await loadSessions();
|
||||
} catch (err: any) {
|
||||
setSessionsError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setSessionsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startTwoFactorSetup = async () => {
|
||||
setTwoFactorError('');
|
||||
setBackupCodes(null);
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
const data = await twoFactorAPI.setup();
|
||||
setSetupData(data);
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAndEnableTwoFactor = async () => {
|
||||
setTwoFactorError('');
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
const result = await twoFactorAPI.verify(verifyCode);
|
||||
setBackupCodes(result.backup_codes || []);
|
||||
setSetupData(null);
|
||||
setVerifyCode('');
|
||||
await loadTwoFactorStatus();
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateTwoFactorBackupCodes = async () => {
|
||||
setTwoFactorError('');
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
const result = await twoFactorAPI.regenerateBackupCodes(regenerateCode);
|
||||
setBackupCodes(result.backup_codes || []);
|
||||
setRegenerateCode('');
|
||||
await loadTwoFactorStatus();
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disableTwoFactor = async () => {
|
||||
setTwoFactorError('');
|
||||
setTwoFactorBusy(true);
|
||||
try {
|
||||
await twoFactorAPI.disable({ password: disablePassword, code: disableCode });
|
||||
setDisablePassword('');
|
||||
setDisableCode('');
|
||||
setBackupCodes(null);
|
||||
setSetupData(null);
|
||||
await loadTwoFactorStatus();
|
||||
} catch (err: any) {
|
||||
setTwoFactorError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setTwoFactorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content settings-page-root">
|
||||
<div className="page-tabs-container">
|
||||
@@ -21,32 +164,240 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<div className="page-content settings-tab-content">
|
||||
<div className="settings-section-modern">
|
||||
|
||||
<div className="settings-grid">
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<div className="setting-icon-modern">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.language}</h4>
|
||||
<p>{t.settings.languageDesc}</p>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">{t.settings.preferences}</h3>
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<div className="setting-icon-modern">
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
</div>
|
||||
<div className="setting-control">
|
||||
<select
|
||||
className="select-modern"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as 'en' | 'it')}
|
||||
>
|
||||
<option value="en">{t.settings.english}</option>
|
||||
<option value="it">{t.settings.italian}</option>
|
||||
</select>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.language}</h4>
|
||||
<p>{t.settings.languageDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-control">
|
||||
<select
|
||||
className="select-modern"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as 'en' | 'it')}
|
||||
>
|
||||
<option value="en">{t.settings.english}</option>
|
||||
<option value="it">{t.settings.italian}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">{t.settings.security}</h3>
|
||||
|
||||
<div className="setting-item-modern">
|
||||
<div className="setting-info-modern">
|
||||
<div className="setting-icon-modern">
|
||||
<span className="material-symbols-outlined">shield</span>
|
||||
</div>
|
||||
<div className="setting-text">
|
||||
<h4>{t.settings.twoFactorTitle}</h4>
|
||||
<p>{t.settings.twoFactorDesc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-control">
|
||||
{twoFactorLoading ? (
|
||||
<span className="badge badge-neutral">{t.common.loading}</span>
|
||||
) : (
|
||||
<span className={`badge ${twoFactorStatus?.enabled ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{twoFactorStatus?.enabled ? t.settings.enabled : t.settings.disabled}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{twoFactorError && <div className="error-message" style={{ marginTop: '1rem' }}>{twoFactorError}</div>}
|
||||
|
||||
{!twoFactorLoading && twoFactorStatus && (
|
||||
<div className="settings-security-details">
|
||||
{!twoFactorStatus.enabled && (
|
||||
<>
|
||||
{!setupData ? (
|
||||
<button className="btn-primary" onClick={startTwoFactorSetup} disabled={twoFactorBusy}>
|
||||
{t.settings.enable2fa}
|
||||
</button>
|
||||
) : (
|
||||
<div className="settings-twofa-setup">
|
||||
<div className="settings-twofa-qr">
|
||||
<img
|
||||
src={`data:image/png;base64,${setupData.qr_code}`}
|
||||
alt={t.settings.qrCodeAlt}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-twofa-meta">
|
||||
<div className="settings-twofa-secret">
|
||||
<div className="settings-twofa-secret-label">{t.settings.secret}</div>
|
||||
<code className="settings-twofa-secret-value">{setupData.secret}</code>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: '1rem' }}>
|
||||
<label htmlFor="verifyCode">{t.settings.verificationCode}</label>
|
||||
<input
|
||||
id="verifyCode"
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
minLength={6}
|
||||
maxLength={8}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-twofa-actions">
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={verifyAndEnableTwoFactor}
|
||||
disabled={twoFactorBusy || verifyCode.trim().length < 6}
|
||||
>
|
||||
{t.settings.verifyEnable2fa}
|
||||
</button>
|
||||
<button
|
||||
className="btn-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSetupData(null);
|
||||
setVerifyCode('');
|
||||
setTwoFactorError('');
|
||||
}}
|
||||
disabled={twoFactorBusy}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{twoFactorStatus.enabled && (
|
||||
<div className="settings-twofa-actions-grid">
|
||||
<div className="settings-twofa-action">
|
||||
<h4>{t.settings.backupCodes}</h4>
|
||||
<p>{t.settings.backupCodesDesc}</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="regenerateCode">{t.settings.verificationCode}</label>
|
||||
<input
|
||||
id="regenerateCode"
|
||||
type="text"
|
||||
value={regenerateCode}
|
||||
onChange={(e) => setRegenerateCode(e.target.value)}
|
||||
minLength={6}
|
||||
maxLength={8}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={regenerateTwoFactorBackupCodes}
|
||||
disabled={twoFactorBusy || regenerateCode.trim().length < 6}
|
||||
>
|
||||
{t.settings.regenerateBackupCodes}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-twofa-action danger">
|
||||
<h4>{t.settings.disable2fa}</h4>
|
||||
<p>{t.settings.disable2faDesc}</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="disablePassword">{t.auth.password}</label>
|
||||
<input
|
||||
id="disablePassword"
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="disableCode">{t.settings.verificationCode}</label>
|
||||
<input
|
||||
id="disableCode"
|
||||
type="text"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value)}
|
||||
minLength={6}
|
||||
maxLength={8}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={disableTwoFactor}
|
||||
disabled={twoFactorBusy || !disablePassword || disableCode.trim().length < 6}
|
||||
>
|
||||
{t.settings.disable2faConfirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="settings-backup-codes-section">
|
||||
<h4>{t.settings.backupCodes}</h4>
|
||||
<p>{t.settings.backupCodesSaveHint}</p>
|
||||
<div className="settings-backup-codes">
|
||||
{backupCodes.map((code) => (
|
||||
<code key={code} className="settings-backup-code">{code}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">{t.settings.sessionsTitle}</h3>
|
||||
<p className="settings-section-desc">{t.settings.sessionsDesc}</p>
|
||||
|
||||
{sessionsError && <div className="error-message">{sessionsError}</div>}
|
||||
|
||||
<div className="settings-sessions-header">
|
||||
<button className="btn-danger" onClick={revokeAllOtherSessions} disabled={sessionsLoading || sessionsBusy}>
|
||||
{t.settings.revokeAllOtherSessions}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessionsLoading ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="settings-empty">{t.settings.sessionsEmpty}</div>
|
||||
) : (
|
||||
<div className="settings-sessions-list">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className="settings-session-row">
|
||||
<div className="settings-session-meta">
|
||||
<div className="settings-session-title">
|
||||
<span className="settings-session-device">{s.device_name || t.settings.unknownDevice}</span>
|
||||
{s.is_current && <span className="badge badge-accent">{t.settings.currentSession}</span>}
|
||||
{!s.is_active && <span className="badge badge-muted">{t.settings.inactiveSession}</span>}
|
||||
</div>
|
||||
<div className="settings-session-details">
|
||||
<span>{s.browser || t.settings.unknownBrowser} • {s.os || t.settings.unknownOs}</span>
|
||||
{s.ip_address && <span> • {s.ip_address}</span>}
|
||||
</div>
|
||||
<div className="settings-session-details">
|
||||
<span>{t.settings.lastActive}: {new Date(s.last_active_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-session-actions">
|
||||
{!s.is_current && s.is_active && (
|
||||
<button className="btn-danger" onClick={() => revokeSession(s.id)} disabled={sessionsBusy}>
|
||||
{t.settings.revokeSession}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
155
frontend/src/pages/admin/Analytics.tsx
Normal file
155
frontend/src/pages/admin/Analytics.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import { analyticsAPI } from '../../api/client';
|
||||
import type { AnalyticsOverview } from '../../api/client';
|
||||
import '../../styles/AdminAnalytics.css';
|
||||
|
||||
export default function Analytics() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||
const [dailyStats, setDailyStats] = useState<{ date: string; active_users: number; new_users: number }[]>([]);
|
||||
const [actions, setActions] = useState<{ action: string; count: number }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [o, usersActivity, breakdown] = await Promise.all([
|
||||
analyticsAPI.overview(),
|
||||
analyticsAPI.userActivity(7),
|
||||
analyticsAPI.actionsBreakdown(24),
|
||||
]);
|
||||
setOverview(o);
|
||||
setDailyStats(usersActivity.daily_stats || []);
|
||||
setActions(breakdown.actions || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const maxActiveUsers = useMemo(() => Math.max(1, ...dailyStats.map((d) => d.active_users)), [dailyStats]);
|
||||
const maxNewUsers = useMemo(() => Math.max(1, ...dailyStats.map((d) => d.new_users)), [dailyStats]);
|
||||
const maxActionCount = useMemo(() => Math.max(1, ...actions.map((a) => a.count)), [actions]);
|
||||
|
||||
return (
|
||||
<main className="main-content admin-analytics-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">analytics</span>
|
||||
<span className="page-title-text">{t.analyticsPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading || !overview ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="analytics-cards">
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.usersTotal}</div>
|
||||
<div className="analytics-card-value">{overview.users.total}</div>
|
||||
<div className="analytics-card-sub">
|
||||
{t.analyticsPage.usersActive}: {overview.users.active}
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.sessionsActive}</div>
|
||||
<div className="analytics-card-value">{overview.sessions.active}</div>
|
||||
</div>
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.logins24h}</div>
|
||||
<div className="analytics-card-value">{overview.security.logins_24h}</div>
|
||||
<div className="analytics-card-sub">
|
||||
{t.analyticsPage.failedLogins24h}: {overview.security.failed_logins_24h}
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-card">
|
||||
<div className="analytics-card-title">{t.analyticsPage.notificationsUnread}</div>
|
||||
<div className="analytics-card-value">{overview.notifications.unread_total}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-grid">
|
||||
<div className="analytics-panel">
|
||||
<h3 className="section-title">{t.analyticsPage.userActivity7d}</h3>
|
||||
<div className="mini-chart">
|
||||
{dailyStats.map((d) => (
|
||||
<div key={d.date} className="mini-chart-row">
|
||||
<div className="mini-chart-label">{d.date}</div>
|
||||
<div className="mini-chart-bars">
|
||||
<div
|
||||
className="mini-bar bar-accent"
|
||||
style={{ width: `${Math.round((d.active_users / maxActiveUsers) * 100)}%` }}
|
||||
title={`${t.analyticsPage.usersActive}: ${d.active_users}`}
|
||||
/>
|
||||
<div
|
||||
className="mini-bar bar-muted"
|
||||
style={{ width: `${Math.round((d.new_users / maxNewUsers) * 100)}%` }}
|
||||
title={`${t.analyticsPage.usersNew}: ${d.new_users}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mini-chart-values">
|
||||
<span>{d.active_users}</span>
|
||||
<span>{d.new_users}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mini-chart-legend">
|
||||
<span className="legend-item"><span className="legend-dot accent" />{t.analyticsPage.usersActive}</span>
|
||||
<span className="legend-item"><span className="legend-dot muted" />{t.analyticsPage.usersNew}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-panel">
|
||||
<h3 className="section-title">{t.analyticsPage.actions24h}</h3>
|
||||
<div className="mini-chart">
|
||||
{actions.slice(0, 12).map((a) => (
|
||||
<div key={a.action} className="mini-chart-row">
|
||||
<div className="mini-chart-label">{a.action}</div>
|
||||
<div className="mini-chart-bars">
|
||||
<div
|
||||
className="mini-bar bar-accent"
|
||||
style={{ width: `${Math.round((a.count / maxActionCount) * 100)}%` }}
|
||||
title={`${a.count}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mini-chart-values">
|
||||
<span>{a.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-footnote">
|
||||
{t.analyticsPage.generatedAt}: {new Date(overview.generated_at).toLocaleString()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
167
frontend/src/pages/admin/AuditLogs.tsx
Normal file
167
frontend/src/pages/admin/AuditLogs.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import { auditAPI } from '../../api/client';
|
||||
import type { AuditLogItem } from '../../api/client';
|
||||
import '../../styles/AdminAudit.css';
|
||||
|
||||
export default function AuditLogs() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
|
||||
const [items, setItems] = useState<AuditLogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [resourceType, setResourceType] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const params = useMemo(() => {
|
||||
const p: Record<string, unknown> = { page, page_size: pageSize };
|
||||
if (username.trim()) p.username = username.trim();
|
||||
if (action.trim()) p.action = action.trim();
|
||||
if (resourceType.trim()) p.resource_type = resourceType.trim();
|
||||
if (status.trim()) p.status = status.trim();
|
||||
return p;
|
||||
}, [page, pageSize, username, action, resourceType, status]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await auditAPI.list(params);
|
||||
setItems(data.items || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setUsername('');
|
||||
setAction('');
|
||||
setResourceType('');
|
||||
setStatus('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="main-content admin-audit-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="material-symbols-outlined">history</span>
|
||||
<span className="page-title-text">{t.auditPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="audit-filters">
|
||||
<input
|
||||
className="audit-input"
|
||||
placeholder={t.auditPage.username}
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<input
|
||||
className="audit-input"
|
||||
placeholder={t.auditPage.action}
|
||||
value={action}
|
||||
onChange={(e) => { setAction(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<input
|
||||
className="audit-input"
|
||||
placeholder={t.auditPage.resourceType}
|
||||
value={resourceType}
|
||||
onChange={(e) => { setResourceType(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<select
|
||||
className="audit-input"
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
|
||||
>
|
||||
<option value="">{t.auditPage.anyStatus}</option>
|
||||
<option value="success">{t.auditPage.statusSuccess}</option>
|
||||
<option value="failure">{t.auditPage.statusFailure}</option>
|
||||
<option value="pending">{t.auditPage.statusPending}</option>
|
||||
<option value="error">{t.auditPage.statusError}</option>
|
||||
</select>
|
||||
<button className="audit-reset-btn" onClick={resetFilters} disabled={loading}>
|
||||
{t.common.reset}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">{t.common.loading}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="audit-empty">{t.auditPage.empty}</div>
|
||||
) : (
|
||||
<div className="audit-table-card">
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t.auditPage.time}</th>
|
||||
<th>{t.auditPage.user}</th>
|
||||
<th>{t.auditPage.action}</th>
|
||||
<th>{t.auditPage.resource}</th>
|
||||
<th>{t.auditPage.status}</th>
|
||||
<th>{t.auditPage.ip}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="mono">{new Date(log.created_at).toLocaleString()}</td>
|
||||
<td>{log.username || '—'}</td>
|
||||
<td className="mono">{log.action}</td>
|
||||
<td className="mono">{log.resource_type || '—'}{log.resource_id ? `:${log.resource_id}` : ''}</td>
|
||||
<td>
|
||||
<span className={`badge ${
|
||||
log.status === 'success' ? 'badge-success' :
|
||||
log.status === 'failure' ? 'badge-muted' :
|
||||
log.status === 'error' ? 'badge-error' :
|
||||
log.status === 'pending' ? 'badge-warning' :
|
||||
'badge-neutral'
|
||||
}`}>{log.status}</span>
|
||||
</td>
|
||||
<td className="mono">{log.ip_address || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="audit-pagination">
|
||||
<button className="btn-link" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>
|
||||
{t.auditPage.prev}
|
||||
</button>
|
||||
<span className="audit-page-indicator">
|
||||
{t.auditPage.page} {page}
|
||||
</span>
|
||||
<button className="btn-link" onClick={() => setPage((p) => p + 1)} disabled={loading || items.length < pageSize}>
|
||||
{t.auditPage.next}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import type { ModuleId } from '../../contexts/ModulesContext';
|
||||
import Feature1Tab from '../../components/admin/Feature1Tab';
|
||||
import '../../styles/AdminPanel.css';
|
||||
|
||||
type TabId = 'config' | 'feature1' | 'feature2' | 'feature3';
|
||||
type TabId = 'config' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
|
||||
|
||||
export default function Features() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const { moduleStates, moduleOrder, setModuleEnabled, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
||||
const { moduleStates, moduleOrder, setModuleEnabled, setModulePosition, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('config');
|
||||
const hasUserMadeChanges = useRef(false);
|
||||
const saveRef = useRef(saveModulesToBackend);
|
||||
@@ -63,47 +63,44 @@ export default function Features() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getModuleDescription = (moduleId: string): string => {
|
||||
const key = `${moduleId}Desc` as keyof typeof t.admin;
|
||||
return t.admin[key] || t.admin.moduleDefaultDesc;
|
||||
};
|
||||
|
||||
const renderModuleToggle = (moduleId: ModuleId) => {
|
||||
const state = moduleStates[moduleId];
|
||||
const adminEnabled = state?.admin ?? true;
|
||||
const userEnabled = state?.user ?? true;
|
||||
|
||||
return (
|
||||
<div className="feature-header">
|
||||
<div className="feature-header-info">
|
||||
<p>{getModuleDescription(moduleId)}</p>
|
||||
</div>
|
||||
<div className="feature-header-actions">
|
||||
<div className={`feature-status-badge ${adminEnabled ? 'active' : ''}`}>
|
||||
{adminEnabled ? t.admin.active : t.admin.inactive}
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.featuresPage?.visibility || 'Visibilità'}</h3>
|
||||
</div>
|
||||
<div className="toggle-group">
|
||||
<span className="toggle-label">{t.admin.adminRole}</span>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminEnabled}
|
||||
onChange={(e) => handleModuleToggle(moduleId, 'admin', e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="toggle-group">
|
||||
<span className="toggle-label">{t.admin.userRole}</span>
|
||||
<label className={`toggle-modern ${!adminEnabled ? 'disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userEnabled}
|
||||
disabled={!adminEnabled}
|
||||
onChange={(e) => handleModuleToggle(moduleId, 'user', e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
<div className="feature-config-options">
|
||||
<div className={`feature-status-badge ${adminEnabled ? 'active' : ''}`}>
|
||||
{adminEnabled ? t.admin.active : t.admin.inactive}
|
||||
</div>
|
||||
<div className="toggle-group">
|
||||
<span className="toggle-label">{t.admin.adminRole}</span>
|
||||
<label className="toggle-modern">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adminEnabled}
|
||||
onChange={(e) => handleModuleToggle(moduleId, 'admin', e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="toggle-group">
|
||||
<span className="toggle-label">{t.admin.userRole}</span>
|
||||
<label className={`toggle-modern ${!adminEnabled ? 'disabled' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={userEnabled}
|
||||
disabled={!adminEnabled}
|
||||
onChange={(e) => handleModuleToggle(moduleId, 'user', e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider-modern"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,17 +128,35 @@ export default function Features() {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetModuleId: string) => {
|
||||
const handleDrop = (e: React.DragEvent, targetModuleId: string, targetSection: 'top' | 'bottom') => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem || draggedItem === targetModuleId) return;
|
||||
e.stopPropagation();
|
||||
if (!draggedItem) return;
|
||||
|
||||
const draggedPosition = moduleStates[draggedItem as ModuleId]?.position || 'top';
|
||||
|
||||
// If dropping on same item, just change section if different
|
||||
if (draggedItem === targetModuleId) {
|
||||
if (draggedPosition !== targetSection) {
|
||||
hasUserMadeChanges.current = true;
|
||||
setModulePosition(draggedItem as ModuleId, targetSection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Change position if moving to different section
|
||||
if (draggedPosition !== targetSection) {
|
||||
hasUserMadeChanges.current = true;
|
||||
setModulePosition(draggedItem as ModuleId, targetSection);
|
||||
}
|
||||
|
||||
// Reorder within the list
|
||||
const newOrder = [...localOrder];
|
||||
const draggedIndex = newOrder.indexOf(draggedItem);
|
||||
const targetIndex = newOrder.indexOf(targetModuleId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return;
|
||||
|
||||
// Remove dragged item and insert at target position
|
||||
newOrder.splice(draggedIndex, 1);
|
||||
newOrder.splice(targetIndex, 0, draggedItem);
|
||||
|
||||
@@ -149,6 +164,19 @@ export default function Features() {
|
||||
setHasOrderChanges(true);
|
||||
};
|
||||
|
||||
const handleSectionDrop = (e: React.DragEvent, section: 'top' | 'bottom') => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem) return;
|
||||
|
||||
const draggedPosition = moduleStates[draggedItem as ModuleId]?.position || 'top';
|
||||
|
||||
// Change position if moving to different section
|
||||
if (draggedPosition !== section) {
|
||||
hasUserMadeChanges.current = true;
|
||||
setModulePosition(draggedItem as ModuleId, section);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyOrder = async () => {
|
||||
try {
|
||||
setModuleOrder(localOrder);
|
||||
@@ -164,15 +192,30 @@ export default function Features() {
|
||||
return module || { id: moduleId, icon: 'extension', defaultEnabled: true };
|
||||
};
|
||||
|
||||
// Split modules by position for the config tab
|
||||
const topOrderModules = localOrder.filter(id => {
|
||||
const state = moduleStates[id as ModuleId];
|
||||
return !state || state.position === 'top';
|
||||
});
|
||||
|
||||
const bottomOrderModules = localOrder.filter(id => {
|
||||
const state = moduleStates[id as ModuleId];
|
||||
return state && state.position === 'bottom';
|
||||
});
|
||||
|
||||
const renderConfigTab = () => {
|
||||
return (
|
||||
<div className="theme-tab-content">
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.featuresPage?.orderSection || 'Ordine nella Sidebar'}</h3>
|
||||
<h3 className="section-title">{t.featuresPage?.topSection || 'Sezione Principale'}</h3>
|
||||
</div>
|
||||
<div className="order-cards">
|
||||
{localOrder.map((moduleId, index) => {
|
||||
<div
|
||||
className="order-cards"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleSectionDrop(e, 'top')}
|
||||
>
|
||||
{topOrderModules.map((moduleId) => {
|
||||
const moduleInfo = getModuleInfo(moduleId);
|
||||
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
|
||||
return (
|
||||
@@ -183,34 +226,76 @@ export default function Features() {
|
||||
onDragStart={(e) => handleDragStart(e, moduleId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, moduleId)}
|
||||
onDrop={(e) => handleDrop(e, moduleId, 'top')}
|
||||
>
|
||||
<div className="order-card-preview">
|
||||
<span className="order-card-number">{index + 1}</span>
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
</div>
|
||||
<div className="order-card-info">
|
||||
<span className="order-card-name">{moduleName}</span>
|
||||
<span className="order-card-desc">{t.featuresPage?.orderDesc || 'Trascina per riordinare'}</span>
|
||||
</div>
|
||||
<div className="order-card-icon">
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
</div>
|
||||
<div className="order-card-handle">
|
||||
<span className="material-symbols-outlined">drag_indicator</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{topOrderModules.length === 0 && (
|
||||
<div className="order-empty">{t.featuresPage?.noModulesTop || 'Nessun modulo in questa sezione'}</div>
|
||||
)}
|
||||
</div>
|
||||
{hasOrderChanges && (
|
||||
<div className="order-actions">
|
||||
<button className="btn-primary" onClick={handleApplyOrder}>
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
{t.featuresPage?.applyOrder || 'Applica'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="theme-section">
|
||||
<div className="section-header">
|
||||
<h3 className="section-title">{t.featuresPage?.bottomSection || 'Sezione Inferiore'}</h3>
|
||||
</div>
|
||||
<div
|
||||
className="order-cards"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleSectionDrop(e, 'bottom')}
|
||||
>
|
||||
{bottomOrderModules.map((moduleId) => {
|
||||
const moduleInfo = getModuleInfo(moduleId);
|
||||
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
|
||||
return (
|
||||
<div
|
||||
key={moduleId}
|
||||
className={`order-card ${draggedItem === moduleId ? 'dragging' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, moduleId)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, moduleId, 'bottom')}
|
||||
>
|
||||
<div className="order-card-preview">
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
</div>
|
||||
<div className="order-card-info">
|
||||
<span className="order-card-name">{moduleName}</span>
|
||||
<span className="order-card-desc">{t.featuresPage?.orderDesc || 'Trascina per riordinare'}</span>
|
||||
</div>
|
||||
<div className="order-card-handle">
|
||||
<span className="material-symbols-outlined">drag_indicator</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{bottomOrderModules.length === 0 && (
|
||||
<div className="order-empty">{t.featuresPage?.noModulesBottom || 'Nessun modulo in questa sezione'}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasOrderChanges && (
|
||||
<div className="order-actions">
|
||||
<button className="btn-primary" onClick={handleApplyOrder}>
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
{t.featuresPage?.applyOrder || 'Applica'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -255,6 +340,32 @@ export default function Features() {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'search':
|
||||
return (
|
||||
<>
|
||||
{renderModuleToggle('search')}
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
</div>
|
||||
<h3>{t.sidebar.search}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'notifications':
|
||||
return (
|
||||
<>
|
||||
{renderModuleToggle('notifications')}
|
||||
<div className="tab-content-placeholder">
|
||||
<div className="placeholder-icon">
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
</div>
|
||||
<h3>{t.sidebar.notifications}</h3>
|
||||
<p>{t.features.comingSoon}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -279,16 +390,15 @@ export default function Features() {
|
||||
<span className="material-symbols-outlined">tune</span>
|
||||
<span>{t.featuresPage?.configTab || 'Configurazione'}</span>
|
||||
</button>
|
||||
{(localOrder.length > 0 ? localOrder : ['feature1', 'feature2', 'feature3']).map((moduleId) => {
|
||||
const moduleInfo = getModuleInfo(moduleId);
|
||||
const moduleName = t.sidebar[moduleId as keyof typeof t.sidebar] || moduleId;
|
||||
{TOGGLEABLE_MODULES.map((module) => {
|
||||
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;
|
||||
return (
|
||||
<button
|
||||
key={moduleId}
|
||||
className={`page-tab-btn ${activeTab === moduleId ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(moduleId as TabId)}
|
||||
key={module.id}
|
||||
className={`page-tab-btn ${activeTab === module.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(module.id as TabId)}
|
||||
>
|
||||
<span className="material-symbols-outlined">{moduleInfo.icon}</span>
|
||||
<span className="material-symbols-outlined">{module.icon}</span>
|
||||
<span>{moduleName}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
154
frontend/src/styles/APIKeys.css
Normal file
154
frontend/src/styles/APIKeys.css
Normal file
@@ -0,0 +1,154 @@
|
||||
.api-keys-root .page-content {
|
||||
max-width: var(--container-lg);
|
||||
}
|
||||
|
||||
/* Section Layout - matches theme-section spacing */
|
||||
.api-keys-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.api-keys-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.api-keys-desc {
|
||||
margin: 0.25rem 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.api-keys-create-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-keys-input {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
height: var(--height-input);
|
||||
padding: 0 0.875rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--input-font-size);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.api-keys-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(var(--color-accent-rgb), 0.45);
|
||||
box-shadow: var(--shadow-ring);
|
||||
}
|
||||
|
||||
.api-keys-created {
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(var(--color-accent-rgb), 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(var(--color-accent-rgb), 0.06);
|
||||
}
|
||||
|
||||
.api-keys-created-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.api-keys-created-key {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-elevated);
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.api-keys-empty {
|
||||
padding: 1.25rem;
|
||||
border: 1px dashed var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.api-keys-table-card {
|
||||
margin-top: 1rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
.api-keys-table-card {
|
||||
background: color-mix(in srgb, var(--color-bg-card) 88%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.api-keys-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.api-keys-table th,
|
||||
.api-keys-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.api-keys-table th {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.api-keys-table tbody tr:hover {
|
||||
background: rgba(var(--color-accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.api-keys-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.api-keys-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.api-keys-muted {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ========== DARK THEME + AUTO ACCENT OVERRIDES ========== */
|
||||
|
||||
/* Input focus */
|
||||
[data-theme='dark'][data-accent='auto'] .api-keys-input:focus {
|
||||
border-color: rgba(229, 231, 235, 0.45);
|
||||
}
|
||||
|
||||
/* Created key box */
|
||||
[data-theme='dark'][data-accent='auto'] .api-keys-created {
|
||||
border-color: rgba(229, 231, 235, 0.25);
|
||||
background: rgba(229, 231, 235, 0.06);
|
||||
}
|
||||
196
frontend/src/styles/AdminAnalytics.css
Normal file
196
frontend/src/styles/AdminAnalytics.css
Normal file
@@ -0,0 +1,196 @@
|
||||
.admin-analytics-root .page-content {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.analytics-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--section-gap-sm);
|
||||
margin-bottom: var(--section-gap);
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
.analytics-card {
|
||||
background: color-mix(in srgb, var(--color-bg-card) 88%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.22);
|
||||
}
|
||||
|
||||
.analytics-card-title {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--badge-font-size);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.analytics-card-value {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
margin-top: var(--space-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.analytics-card-sub {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--section-gap-sm);
|
||||
}
|
||||
|
||||
.analytics-panel {
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
.analytics-panel {
|
||||
background: color-mix(in srgb, var(--color-bg-card) 88%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-panel:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.analytics-panel .section-title {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.mini-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mini-chart-row {
|
||||
display: grid;
|
||||
grid-template-columns: 95px 1fr 64px;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mini-chart-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-chart-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mini-bar {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mini-bar.bar-accent {
|
||||
background: rgba(var(--color-accent-rgb), 0.75);
|
||||
}
|
||||
|
||||
.mini-bar.bar-muted {
|
||||
background: rgba(156, 163, 175, 0.6);
|
||||
}
|
||||
|
||||
.mini-chart-values {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.mini-chart-legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-dot.accent {
|
||||
background: rgba(var(--color-accent-rgb), 0.75);
|
||||
}
|
||||
|
||||
.legend-dot.muted {
|
||||
background: rgba(156, 163, 175, 0.6);
|
||||
}
|
||||
|
||||
.analytics-footnote {
|
||||
margin-top: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.analytics-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.analytics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.mini-chart-row {
|
||||
grid-template-columns: 80px 1fr 56px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== DARK THEME + AUTO ACCENT OVERRIDES ========== */
|
||||
|
||||
/* Mini bar accent color */
|
||||
[data-theme='dark'][data-accent='auto'] .mini-bar.bar-accent {
|
||||
background: rgba(229, 231, 235, 0.75);
|
||||
}
|
||||
|
||||
/* Legend dot accent */
|
||||
[data-theme='dark'][data-accent='auto'] .legend-dot.accent {
|
||||
background: rgba(229, 231, 235, 0.75);
|
||||
}
|
||||
149
frontend/src/styles/AdminAudit.css
Normal file
149
frontend/src/styles/AdminAudit.css
Normal file
@@ -0,0 +1,149 @@
|
||||
.admin-audit-root .page-content {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: var(--section-gap);
|
||||
}
|
||||
|
||||
.audit-reset-btn {
|
||||
margin-left: auto;
|
||||
height: var(--height-input);
|
||||
padding: 0 1rem;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.audit-reset-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.audit-reset-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.audit-input {
|
||||
height: var(--height-input);
|
||||
padding: 0 0.875rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.85rem;
|
||||
min-width: 160px;
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.audit-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(var(--color-accent-rgb), 0.45);
|
||||
box-shadow: var(--shadow-ring);
|
||||
}
|
||||
|
||||
.audit-table-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
.audit-table-card {
|
||||
background: color-mix(in srgb, var(--color-bg-card) 88%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.audit-table th,
|
||||
.audit-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.audit-table tbody tr:hover {
|
||||
background: rgba(var(--color-accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.audit-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.audit-table .mono {
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.audit-empty {
|
||||
padding: 2rem 1.25rem;
|
||||
border: 1px dashed var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.audit-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-card-outline);
|
||||
}
|
||||
|
||||
.audit-page-indicator {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========== DARK THEME OVERRIDES ========== */
|
||||
|
||||
/* Reset button - light in dark mode */
|
||||
[data-theme='dark'] .audit-reset-btn {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .audit-reset-btn:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Input focus - auto accent */
|
||||
[data-theme='dark'][data-accent='auto'] .audit-input:focus {
|
||||
border-color: rgba(229, 231, 235, 0.45);
|
||||
}
|
||||
@@ -52,6 +52,18 @@
|
||||
border: 1px solid rgba(var(--color-accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
/* Toolbar - single row with search left, badges+button right */
|
||||
.admin-panel-root .users-toolbar,
|
||||
.users-root .users-toolbar {
|
||||
@@ -101,6 +113,123 @@
|
||||
font-size: var(--icon-md);
|
||||
}
|
||||
|
||||
/* Link-style Button */
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: var(--color-accent-hover);
|
||||
background: rgba(var(--color-accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-link.danger:hover {
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.btn-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Danger Button */
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: var(--btn-padding-md);
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: var(--btn-font-size);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base), filter var(--transition-base);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Ghost Button */
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: var(--btn-padding-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: var(--btn-font-size);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn-ghost.danger {
|
||||
color: var(--color-error);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-ghost.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--color-error);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
padding: 2rem 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Small button variant */
|
||||
.btn-sm {
|
||||
padding: var(--btn-padding-sm) !important;
|
||||
@@ -389,21 +518,26 @@
|
||||
.admin-panel-root .users-table td,
|
||||
.users-root .users-table th,
|
||||
.users-root .users-table td {
|
||||
padding: var(--table-cell-padding);
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-panel-root .users-table tbody tr:not(:last-child),
|
||||
.users-root .users-table tbody tr:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.admin-panel-root .users-table tr:last-child td,
|
||||
.users-root .users-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-panel-root .users-table th,
|
||||
.users-root .users-table th {
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-elevated);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -436,7 +570,7 @@
|
||||
|
||||
.admin-panel-root .users-table tbody tr:hover,
|
||||
.users-root .users-table tbody tr:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
background: rgba(var(--color-accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.admin-panel-root .user-cell,
|
||||
@@ -1112,16 +1246,22 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.modern-table th {
|
||||
padding: 1rem 1.5rem;
|
||||
.modern-table th,
|
||||
.modern-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modern-table th {
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modern-table th.actions-col {
|
||||
@@ -1129,18 +1269,12 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modern-table td {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.modern-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modern-table tbody tr:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
background: rgba(var(--color-accent-rgb), 0.05);
|
||||
}
|
||||
|
||||
.modern-table .user-info {
|
||||
@@ -2134,43 +2268,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Feature Header - Clean style for feature toggles */
|
||||
.feature-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.feature-header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-header-info h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.feature-header-info p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.feature-header-actions {
|
||||
/* Feature Config Options */
|
||||
.feature-config-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.25rem;
|
||||
/* Align with text top */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Status badge in header */
|
||||
@@ -2188,9 +2291,15 @@
|
||||
}
|
||||
|
||||
.feature-status-badge.active {
|
||||
background: rgba(var(--color-accent-rgb), 0.1);
|
||||
color: var(--color-accent);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.2);
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: #047857;
|
||||
border-color: rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .feature-status-badge.active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
|
||||
.feature-status-badge::before {
|
||||
@@ -2227,38 +2336,10 @@
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
/* Feature Header Mobile - Stack description above toggles */
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.feature-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-header-info {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-header-info h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.feature-header-info p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.feature-header-actions {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.feature-header-actions .toggle-group {
|
||||
justify-content: center;
|
||||
.feature-config-options {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Admin Tab Tooltip - Mobile Only */
|
||||
@@ -2342,11 +2423,9 @@
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Feature status badge active */
|
||||
[data-theme='dark'][data-accent='auto'] .feature-status-badge.active {
|
||||
background: rgba(229, 231, 235, 0.1);
|
||||
color: #e5e7eb;
|
||||
border-color: rgba(229, 231, 235, 0.2);
|
||||
/* Ghost button hover */
|
||||
[data-theme='dark'][data-accent='auto'] .btn-ghost:hover {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
@@ -2379,6 +2458,22 @@
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
/* Link button with auto accent */
|
||||
[data-theme='dark'][data-accent='auto'] .btn-link {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
[data-theme='dark'][data-accent='auto'] .btn-link:hover {
|
||||
color: #f3f4f6;
|
||||
background: rgba(229, 231, 235, 0.12);
|
||||
}
|
||||
|
||||
/* Order card icon with auto accent */
|
||||
[data-theme='dark'][data-accent='auto'] .order-card-preview .material-symbols-outlined {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
|
||||
/* ===========================================
|
||||
ORDER CARDS - Feature Ordering (Theme Editor Style)
|
||||
=========================================== */
|
||||
@@ -2436,9 +2531,8 @@
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.order-card-number {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
.order-card-preview .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -2462,23 +2556,6 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Order Card Icon */
|
||||
.order-card-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(var(--color-accent-rgb), 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.order-card-icon .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Order Card Handle */
|
||||
.order-card-handle {
|
||||
flex-shrink: 0;
|
||||
@@ -2503,6 +2580,48 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Order Card Position Button */
|
||||
.order-card-position-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.order-card-position-btn:hover {
|
||||
background: rgba(var(--color-accent-rgb), 0.1);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.order-card-position-btn .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.order-card-position-btn:hover .material-symbols-outlined {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Order Empty State */
|
||||
.order-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
border: 1px dashed var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(var(--color-accent-rgb), 0.02);
|
||||
}
|
||||
|
||||
/* Order Actions */
|
||||
.order-actions {
|
||||
margin-top: 1.5rem;
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
/* Standard Section Title */
|
||||
.section-title {
|
||||
margin: 0;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -203,7 +203,7 @@
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background: rgba(var(--color-accent-rgb), 0.1);
|
||||
background-color: rgba(var(--color-accent-rgb), 0.1);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
font-size: var(--icon-lg);
|
||||
}
|
||||
|
||||
|
||||
/* ========== ACTION BUTTONS IN SLIDER ========== */
|
||||
|
||||
/* Action buttons that appear in the slider (like Add User) */
|
||||
@@ -286,9 +287,30 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Show mobile menu button */
|
||||
/* Show mobile menu button with logo */
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
background-image: url('/logo_black.svg');
|
||||
background-size: 28px 28px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.mobile-menu-btn .material-symbols-outlined {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .mobile-menu-btn {
|
||||
background-image: url('/logo_white.svg');
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.page-tabs-container,
|
||||
@@ -302,6 +324,7 @@
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-title-section,
|
||||
@@ -309,6 +332,7 @@
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-left: 48px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -323,6 +347,35 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide title section when tabs are present on mobile */
|
||||
.page-tabs-slider:has(.page-tab-btn) .page-title-section,
|
||||
.admin-tabs-slider:has(.admin-tab-btn) .admin-title-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Center title section absolutely when no tabs are present on mobile */
|
||||
.page-tabs-slider:not(:has(.page-tab-btn)),
|
||||
.admin-tabs-slider:not(:has(.admin-tab-btn)) {
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.page-tabs-slider:not(:has(.page-tab-btn)) .page-title-section,
|
||||
.admin-tabs-slider:not(:has(.admin-tab-btn)) .admin-title-section {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 0.5rem 0.75rem;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Lighter icon color in dark theme when only title is shown */
|
||||
.page-tabs-slider:not(:has(.page-tab-btn)) .page-title-section .material-symbols-outlined,
|
||||
.admin-tabs-slider:not(:has(.admin-tab-btn)) .admin-title-section .material-symbols-outlined {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Tabs on second row - full width */
|
||||
.page-tab-btn,
|
||||
.admin-tab-btn {
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
.login-container .error-message {
|
||||
background: rgba(245, 101, 101, 0.1);
|
||||
color: var(--color-error);
|
||||
padding: 0.75rem;
|
||||
|
||||
202
frontend/src/styles/Notifications.css
Normal file
202
frontend/src/styles/Notifications.css
Normal file
@@ -0,0 +1,202 @@
|
||||
.notifications-root .page-content {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.notifications-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--toolbar-gap);
|
||||
margin-bottom: var(--section-gap);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notifications-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap-lg);
|
||||
}
|
||||
|
||||
.notifications-toggle-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.notifications-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--element-gap-lg);
|
||||
}
|
||||
|
||||
.notifications-empty {
|
||||
padding: 2rem 1.25rem;
|
||||
border: 1px dashed var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
.notifications-empty {
|
||||
background: color-mix(in srgb, var(--color-bg-card) 88%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
.notification-item {
|
||||
background: color-mix(in srgb, var(--color-bg-card) 88%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
border-color: rgba(var(--color-accent-rgb), 0.35);
|
||||
box-shadow: var(--shadow-sm), 0 0 0 3px rgba(var(--color-accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.notification-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-date {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.notification-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.notification-type.type-success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.notification-type.type-warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.notification-type.type-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.notification-type.type-system {
|
||||
background: rgba(var(--color-accent-rgb), 0.1);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.2);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notification-item {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== DARK THEME + AUTO ACCENT OVERRIDES ========== */
|
||||
|
||||
/* Unread notification border */
|
||||
[data-theme='dark'][data-accent='auto'] .notification-item.unread {
|
||||
border-color: rgba(229, 231, 235, 0.35);
|
||||
box-shadow: var(--shadow-sm), 0 0 0 3px rgba(229, 231, 235, 0.08);
|
||||
}
|
||||
|
||||
/* System type badge */
|
||||
[data-theme='dark'][data-accent='auto'] .notification-type.type-system {
|
||||
background: rgba(229, 231, 235, 0.1);
|
||||
border-color: rgba(229, 231, 235, 0.2);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
@@ -17,27 +17,22 @@
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Settings Sections */
|
||||
.settings-section-modern {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 1.5rem;
|
||||
/* Settings Sections - no card, just spacing */
|
||||
.settings-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, black, transparent)) {
|
||||
.settings-section-modern {
|
||||
background: color-mix(in srgb, var(--color-bg-card) 88%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section-modern:last-child {
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section-desc {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Section title uses standard .section-title from Layout.css */
|
||||
|
||||
.setting-item-modern {
|
||||
@@ -45,17 +40,14 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.setting-item-modern:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-item-modern:first-child {
|
||||
padding-top: 0;
|
||||
.setting-item-modern + .setting-item-modern {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.setting-info-modern {
|
||||
@@ -181,3 +173,213 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Security section helpers */
|
||||
.settings-security-details {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-security-details > .btn-primary {
|
||||
align-self: flex-start;
|
||||
justify-content: center;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.settings-backup-codes-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.settings-backup-codes-section h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.settings-backup-codes-section p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-twofa-setup {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-twofa-qr {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.settings-twofa-qr img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-twofa-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-twofa-secret {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.settings-twofa-secret-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.settings-twofa-secret-value {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.settings-twofa-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-twofa-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-twofa-action h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.settings-twofa-action p {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-twofa-action.danger {
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
}
|
||||
|
||||
.settings-backup-codes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-backup-code {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-sessions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settings-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-session-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.settings-session-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-session-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-session-device {
|
||||
font-weight: 650;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.settings-session-details {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-session-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.settings-empty {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px dashed var(--color-card-outline);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-twofa-setup {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-twofa-actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-session-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-session-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
.sidebar.dynamic.collapsed.expanded-force .view-mode-toggle {
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
}
|
||||
|
||||
.sidebar.dynamic.collapsed .view-mode-toggle {
|
||||
padding: 0.75rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Re-enable transitions ONLY on hover or when forced expanded */
|
||||
@@ -356,6 +356,15 @@
|
||||
/* Taller touch target */
|
||||
}
|
||||
|
||||
button.nav-item {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
@@ -422,21 +431,50 @@
|
||||
transition: opacity 0.3s ease, width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
margin-left: 0;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.sidebar-bottom-actions {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.75rem;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
border-top: 1px solid var(--color-sidebar-border);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-top: auto;
|
||||
/* Push to bottom */
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer>*:not(:last-child) {
|
||||
.sidebar-footer>*:not(:last-child):not(.nav-item) {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
@@ -445,7 +483,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.25rem 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -486,7 +525,7 @@
|
||||
|
||||
.sidebar.collapsed .view-mode-toggle {
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
gap: 0;
|
||||
/* Ensure no gap affects centering */
|
||||
}
|
||||
@@ -667,6 +706,8 @@
|
||||
|
||||
.sidebar.collapsed .view-mode-toggle {
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .user-info-compact {
|
||||
@@ -1050,16 +1091,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== DARK THEME + AUTO ACCENT OVERRIDES ========== */
|
||||
/* ========== AUTO ACCENT OVERRIDES ========== */
|
||||
|
||||
/* Nav items with auto accent in dark mode: use off-white background with dark text */
|
||||
[data-theme='dark'][data-accent='auto'] .nav-item.active {
|
||||
/*
|
||||
* Sidebar has a dark background in BOTH light and dark themes.
|
||||
* With auto accent color, we need high-contrast active states.
|
||||
* Use off-white background with dark text for visibility.
|
||||
*/
|
||||
|
||||
/* Nav items with auto accent: use off-white background with dark text */
|
||||
[data-accent='auto'] .nav-item.active {
|
||||
background: #f3f4f6;
|
||||
color: #111827 !important;
|
||||
box-shadow: 0 0 16px 2px rgba(243, 244, 246, 0.5);
|
||||
}
|
||||
|
||||
[data-accent='auto'] .nav-item.active .nav-icon,
|
||||
[data-accent='auto'] .nav-item.active .nav-label {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
/* Active indicator bar */
|
||||
[data-accent='auto'] .nav-item.active::before {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
/* Hover on active item - slightly darker */
|
||||
[data-accent='auto'] .nav-item.active:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111827 !important;
|
||||
box-shadow: 0 0 20px 2px rgba(229, 231, 235, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='dark'][data-accent='auto'] .nav-item.active .nav-icon,
|
||||
[data-theme='dark'][data-accent='auto'] .nav-item.active .nav-label {
|
||||
color: #111827 !important;
|
||||
/* Hover states for non-active items */
|
||||
[data-accent='auto'] .nav-item:not(.active):hover {
|
||||
background: rgba(229, 231, 235, 0.15);
|
||||
}
|
||||
|
||||
/* View mode toggle with auto accent */
|
||||
[data-accent='auto'] .view-mode-toggle:hover {
|
||||
background: rgba(229, 231, 235, 0.12);
|
||||
border-color: rgba(229, 231, 235, 0.2);
|
||||
}
|
||||
|
||||
[data-accent='auto'] .view-mode-toggle.user-mode {
|
||||
background: rgba(229, 231, 235, 0.18);
|
||||
border-color: rgba(229, 231, 235, 0.25);
|
||||
}
|
||||
|
||||
[data-accent='auto'] .view-mode-toggle.user-mode:hover {
|
||||
background: rgba(229, 231, 235, 0.22);
|
||||
border-color: rgba(229, 231, 235, 0.3);
|
||||
}
|
||||
|
||||
/* User initial badge with auto accent */
|
||||
[data-accent='auto'] .user-initial {
|
||||
background: rgba(229, 231, 235, 0.25);
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
.theme-settings-root .badge {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8rem;
|
||||
@@ -552,12 +552,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-accent {
|
||||
.theme-settings-root .badge-accent {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
.theme-settings-root .badge-success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
|
||||
@@ -91,22 +91,24 @@
|
||||
|
||||
.users-root .users-table th,
|
||||
.users-root .users-table td {
|
||||
padding: var(--table-cell-padding);
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.users-root .users-table tbody tr:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-card-outline);
|
||||
.users-root .users-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.users-root .users-table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-elevated);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.users-root .users-table tbody tr:hover {
|
||||
|
||||
@@ -46,6 +46,12 @@ body {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Utility for monospace text (IDs, keys, timestamps) */
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface User {
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
@@ -42,14 +43,17 @@ export interface UserUpdatePayload {
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
access_token: string;
|
||||
access_token: string | null;
|
||||
token_type: string;
|
||||
requires_2fa?: boolean;
|
||||
temp_token?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
login: (username: string, password: string) => Promise<{ requires_2fa: boolean; temp_token?: string | null }>;
|
||||
verify2fa: (tempToken: string, code: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
|
||||
Reference in New Issue
Block a user