landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json .
|
||||
RUN npm install
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Build for production
|
||||
RUN npm run build
|
||||
|
||||
# Serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=0 /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "landvex-admin-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"zustand": "^4.4.7",
|
||||
"axios": "^1.6.5",
|
||||
"lucide-react": "^0.303.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.33",
|
||||
"recharts": "^2.10.4",
|
||||
"date-fns": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.0",
|
||||
"@typescript-eslint/parser": "^6.18.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vitest": "^1.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { Dashboard } from '@/pages/Dashboard';
|
||||
import { TenantList } from '@/pages/Tenants/TenantList';
|
||||
import { UserList } from '@/pages/Users/UserList';
|
||||
import { RoleEditor } from '@/pages/Roles/RoleEditor';
|
||||
import { BillingPage } from '@/pages/Billing/BillingPage';
|
||||
import { AuditLogPage } from '@/pages/AuditLogs/AuditLogPage';
|
||||
import { ReportBuilder } from '@/pages/Reports/ReportBuilder';
|
||||
import { PluginManager } from '@/pages/Plugins/PluginManager';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/tenants" element={<TenantList />} />
|
||||
<Route path="/tenants/:id" element={<div>Tenant Detail</div>} />
|
||||
<Route path="/users" element={<UserList />} />
|
||||
<Route path="/users/:id" element={<div>User Detail</div>} />
|
||||
<Route path="/roles" element={<div>Roles List</div>} />
|
||||
<Route path="/roles/:id" element={<RoleEditor />} />
|
||||
<Route path="/api-keys" element={<div>API Keys</div>} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
<Route path="/audit-logs" element={<AuditLogPage />} />
|
||||
<Route path="/reports" element={<ReportBuilder />} />
|
||||
<Route path="/reports/new" element={<div>New Report</div>} />
|
||||
<Route path="/plugins" element={<PluginManager />} />
|
||||
<Route path="/settings" element={<div>Settings</div>} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TopBar } from './TopBar';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
Users,
|
||||
KeyRound,
|
||||
CreditCard,
|
||||
ClipboardList,
|
||||
BarChart3,
|
||||
Puzzle,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard, permission: null },
|
||||
{ name: 'Tenants', href: '/tenants', icon: Building2, permission: 'tenants.read' },
|
||||
{ name: 'Users', href: '/users', icon: Users, permission: 'users.read' },
|
||||
{ name: 'Roles', href: '/roles', icon: ShieldCheck, permission: 'roles.read' },
|
||||
{ name: 'API Keys', href: '/api-keys', icon: KeyRound, permission: 'api_keys.read' },
|
||||
{ name: 'Billing', href: '/billing', icon: CreditCard, permission: 'billing.read' },
|
||||
{ name: 'Audit Logs', href: '/audit-logs', icon: ClipboardList, permission: 'audit.read' },
|
||||
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: 'reports.read' },
|
||||
{ name: 'Plugins', href: '/plugins', icon: Puzzle, permission: 'plugins.install' },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings, permission: 'settings.read' },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
const filteredNav = navigation.filter(
|
||||
(item) => !item.permission || hasPermission(item.permission)
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
|
||||
<div className="h-16 flex items-center px-6 border-b border-gray-200">
|
||||
<span className="text-xl font-bold text-indigo-600">🏠 LandveX</span>
|
||||
</div>
|
||||
<nav className="flex-1 px-4 py-4 space-y-1">
|
||||
{filteredNav.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-50 text-indigo-700'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-500">
|
||||
LandveX Enterprise v1.0
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, Bell, User } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export function TopBar() {
|
||||
const { user, logout } = useAuth();
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="relative w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tenants, users, reports..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="relative p-2 text-gray-500 hover:text-gray-700">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowProfile(!showProfile)}
|
||||
className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div className="text-sm text-left">
|
||||
<div className="font-medium text-gray-900">
|
||||
{user?.first_name} {user?.last_name}
|
||||
</div>
|
||||
<div className="text-gray-500">{user?.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showProfile && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1">
|
||||
<a
|
||||
href="/profile"
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Profile
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<hr className="my-1" />
|
||||
<button
|
||||
onClick={logout}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-50"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useAuth() {
|
||||
const navigate = useNavigate();
|
||||
const { user, tokens, setAuth, clearAuth } = useAuthStore();
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string, mfaCode?: string) => {
|
||||
const response = await api.post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
mfa_code: mfaCode,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token, user: userData } = response.data;
|
||||
setAuth(userData, { accessToken: access_token, refreshToken: refresh_token });
|
||||
navigate('/');
|
||||
return response.data;
|
||||
},
|
||||
[navigate, setAuth]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
clearAuth();
|
||||
navigate('/login');
|
||||
}
|
||||
}, [navigate, clearAuth]);
|
||||
|
||||
const refreshToken = useCallback(async () => {
|
||||
if (!tokens?.refreshToken) return;
|
||||
|
||||
const response = await api.post('/auth/refresh', {
|
||||
refresh_token: tokens.refreshToken,
|
||||
});
|
||||
|
||||
setAuth(user, {
|
||||
accessToken: response.data.access_token,
|
||||
refreshToken: response.data.refresh_token || tokens.refreshToken,
|
||||
});
|
||||
}, [tokens, user, setAuth]);
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated: !!tokens?.accessToken,
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export function usePermissions() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const permissions = useMemo(() => {
|
||||
if (!user?.roles) return new Set<string>();
|
||||
|
||||
const perms = new Set<string>();
|
||||
user.roles.forEach((role: { permissions: string[] }) => {
|
||||
role.permissions.forEach((p: string) => perms.add(p));
|
||||
});
|
||||
return perms;
|
||||
}, [user]);
|
||||
|
||||
const hasPermission = useMemo(() => {
|
||||
return (permission: string): boolean => {
|
||||
if (permissions.has('*')) return true;
|
||||
if (permissions.has(permission)) return true;
|
||||
|
||||
// Check wildcard permissions (e.g., "users.*" matches "users.read")
|
||||
const parts = permission.split('.');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const wildcard = parts.slice(0, i).join('.') + '.*';
|
||||
if (permissions.has(wildcard)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}, [permissions]);
|
||||
|
||||
const hasAnyPermission = useMemo(() => {
|
||||
return (required: string[]): boolean => {
|
||||
return required.some((p) => hasPermission(p));
|
||||
};
|
||||
}, [hasPermission]);
|
||||
|
||||
const hasAllPermissions = useMemo(() => {
|
||||
return (required: string[]): boolean => {
|
||||
return required.every((p) => hasPermission(p));
|
||||
};
|
||||
}, [hasPermission]);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
hasAllPermissions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'https://api.landvex.com/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token and tenant header
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const { tokens, user } = useAuthStore.getState();
|
||||
|
||||
if (tokens?.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${tokens.accessToken}`;
|
||||
}
|
||||
|
||||
if (user?.tenant_id) {
|
||||
config.headers['X-Tenant-ID'] = user.tenant_id;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor - handle token refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const { tokens } = useAuthStore.getState();
|
||||
if (!tokens?.refreshToken) throw new Error('No refresh token');
|
||||
|
||||
const response = await axios.post(
|
||||
`${api.defaults.baseURL}/auth/refresh`,
|
||||
{ refresh_token: tokens.refreshToken }
|
||||
);
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
useAuthStore.getState().setAuth(useAuthStore.getState().user, {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token || tokens.refreshToken,
|
||||
});
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Type-safe API helpers
|
||||
export async function get<T>(url: string, params?: Record<string, unknown>) {
|
||||
const response = await api.get<T>(url, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function post<T>(url: string, data?: unknown) {
|
||||
const response = await api.post<T>(url, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function patch<T>(url: string, data?: unknown) {
|
||||
const response = await api.patch<T>(url, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function del<T>(url: string) {
|
||||
const response = await api.delete<T>(url);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface AcademyStats {
|
||||
total_enrolled: number;
|
||||
completed_all: number;
|
||||
completion_rate: number;
|
||||
average_progress: number;
|
||||
}
|
||||
|
||||
interface UserProgress {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
completed_lessons: number;
|
||||
total_progress: number;
|
||||
certificate_earned: boolean;
|
||||
last_active: string;
|
||||
}
|
||||
|
||||
const AcademyManagement: React.FC = () => {
|
||||
const [stats, setStats] = useState<AcademyStats | null>(null);
|
||||
const [users, setUsers] = useState<UserProgress[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/academy/stats');
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
// Mock data - replace with actual API call
|
||||
const mockUsers: UserProgress[] = [
|
||||
{ user_id: '1', user_name: 'Anna Svensson', completed_lessons: 24, total_progress: 100, certificate_earned: true, last_active: '2026-07-03' },
|
||||
{ user_id: '2', user_name: 'Erik Johansson', completed_lessons: 18, total_progress: 75, certificate_earned: false, last_active: '2026-07-02' },
|
||||
{ user_id: '3', user_name: 'Maria Karlsson', completed_lessons: 12, total_progress: 50, certificate_earned: false, last_active: '2026-07-01' },
|
||||
{ user_id: '4', user_name: 'Lars Andersson', completed_lessons: 6, total_progress: 25, certificate_earned: false, last_active: '2026-06-30' },
|
||||
{ user_id: '5', user_name: 'Lisa Nilsson', completed_lessons: 0, total_progress: 0, certificate_earned: false, last_active: '2026-06-28' },
|
||||
];
|
||||
setUsers(mockUsers);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moduleData = [
|
||||
{ name: 'Modul 1', completed: 45, in_progress: 12, not_started: 8 },
|
||||
{ name: 'Modul 2', completed: 38, in_progress: 15, not_started: 12 },
|
||||
{ name: 'Modul 3', completed: 32, in_progress: 18, not_started: 15 },
|
||||
{ name: 'Modul 4', completed: 28, in_progress: 20, not_started: 17 },
|
||||
{ name: 'Modul 5', completed: 22, in_progress: 22, not_started: 21 },
|
||||
{ name: 'Modul 6', completed: 18, in_progress: 20, not_started: 27 },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Laddar...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">quiXzoom Academy</h1>
|
||||
<Button onClick={() => window.open('https://quixzoom.com/academy', '_blank')}>
|
||||
Öppna Academy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">Totalt inskrivna</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{stats?.total_enrolled || 65}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">Färdiga (certifikat)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">{stats?.completed_all || 18}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">Genomströmning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{stats?.completion_rate || 27.7}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">Snitt progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{stats?.average_progress || 42.3}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Module Progress Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Progress per modul</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={moduleData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="completed" fill="#00C853" name="Färdiga" />
|
||||
<Bar dataKey="in_progress" fill="#0066FF" name="Pågående" />
|
||||
<Bar dataKey="not_started" fill="#F5F5F7" name="Ej startade" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zoomers progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Namn</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead>Lektioner</TableHead>
|
||||
<TableHead>Certifikat</TableHead>
|
||||
<TableHead>Senast aktiv</TableHead>
|
||||
<TableHead>Åtgärder</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell className="font-medium">{user.user_name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="w-32">
|
||||
<Progress value={user.total_progress} />
|
||||
<span className="text-xs text-gray-500">{user.total_progress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.completed_lessons} / 24</TableCell>
|
||||
<TableCell>
|
||||
{user.certificate_earned ? (
|
||||
<Badge className="bg-green-100 text-green-800">✓ Utfärdat</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Ej än</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{user.last_active}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="outline" size="sm">
|
||||
Visa detaljer
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content Management */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Innehållshantering</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-semibold">Modul 1: Kom igång</h3>
|
||||
<p className="text-sm text-gray-500">4 lektioner · 35 minuter</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">Redigera</Button>
|
||||
<Button variant="outline" size="sm">Förhandsgranska</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-semibold">Modul 2: Fotokvalitet</h3>
|
||||
<p className="text-sm text-gray-500">4 lektioner · 52 minuter</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">Redigera</Button>
|
||||
<Button variant="outline" size="sm">Förhandsgranska</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-semibold">Modul 3: GPS och plats</h3>
|
||||
<p className="text-sm text-gray-500">3 lektioner · 30 minuter</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">Redigera</Button>
|
||||
<Button variant="outline" size="sm">Förhandsgranska</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full">
|
||||
+ Lägg till ny modul
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcademyManagement;
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, Download, Filter, AlertTriangle, Info, AlertCircle } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
user: { id: string; email: string } | null;
|
||||
details: Record<string, unknown>;
|
||||
ip_address: string;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function AuditLogPage() {
|
||||
const { hasPermission } = usePermissions();
|
||||
const [search, setSearch] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['audit-logs', { search, action: actionFilter, severity: severityFilter, from: dateFrom, to: dateTo }],
|
||||
queryFn: () =>
|
||||
api.get('/audit-logs', {
|
||||
params: { search, action: actionFilter, severity: severityFilter, from: dateFrom, to: dateTo },
|
||||
}),
|
||||
});
|
||||
|
||||
const logs: AuditLog[] = data?.data ?? [];
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-600" />;
|
||||
case 'warning':
|
||||
return <AlertCircle className="w-4 h-4 text-yellow-600" />;
|
||||
default:
|
||||
return <Info className="w-4 h-4 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'bg-red-50 text-red-700';
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 text-yellow-700';
|
||||
default:
|
||||
return 'bg-blue-50 text-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasPermission('audit.read')) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">You don't have permission to view audit logs.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Audit Logs</h1>
|
||||
{hasPermission('audit.export') && (
|
||||
<button className="flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
<option value="create">Create</option>
|
||||
<option value="update">Update</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="login">Login</option>
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">All Severities</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm text-gray-600">From:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm text-gray-600">To:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Severity
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-4 text-center text-gray-500">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-4 text-center text-gray-500">
|
||||
No audit logs found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${getSeverityColor(log.severity)}`}>
|
||||
{getSeverityIcon(log.severity)}
|
||||
<span className="ml-1 capitalize">{log.severity}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{log.user?.email ?? 'System'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{log.resource_type}/{log.resource_id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
|
||||
{log.ip_address}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CreditCard, Download, Calendar, AlertCircle } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoice_number: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
total_amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
|
||||
due_date: string;
|
||||
pdf_url: string | null;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
plan: { name: string; price_monthly: number };
|
||||
status: string;
|
||||
current_period_end: string;
|
||||
cancel_at_period_end: boolean;
|
||||
}
|
||||
|
||||
export function BillingPage() {
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
const { data: subscription } = useQuery<Subscription>({
|
||||
queryKey: ['billing', 'subscription'],
|
||||
queryFn: () => api.get('/billing/subscription'),
|
||||
});
|
||||
|
||||
const { data: invoicesData } = useQuery({
|
||||
queryKey: ['billing', 'invoices'],
|
||||
queryFn: () => api.get('/billing/invoices'),
|
||||
});
|
||||
|
||||
const invoices: Invoice[] = invoicesData?.data ?? [];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'overdue':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'sent':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasPermission('billing.read')) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">You don't have permission to view billing.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Billing</h1>
|
||||
|
||||
{/* Current Subscription */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-gray-900">Current Plan</h2>
|
||||
<p className="mt-1 text-3xl font-bold text-gray-900">
|
||||
{subscription?.plan?.name ?? 'Free'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{subscription?.plan?.price_monthly ?? 0} SEK/month
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-50 rounded-lg">
|
||||
<CreditCard className="w-6 h-6 text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center text-sm text-gray-600">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Next billing: {subscription?.current_period_end
|
||||
? new Date(subscription.current_period_end).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</div>
|
||||
|
||||
{subscription?.cancel_at_period_end && (
|
||||
<div className="mt-4 flex items-center text-sm text-yellow-700 bg-yellow-50 p-3 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
Your subscription will cancel at the end of this period.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center space-x-3">
|
||||
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
Upgrade Plan
|
||||
</button>
|
||||
<button className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
|
||||
Cancel Subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Payment Methods</h2>
|
||||
<button className="px-3 py-1.5 text-sm bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100">
|
||||
+ Add Card
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center p-3 bg-gray-50 rounded-lg">
|
||||
<CreditCard className="w-5 h-5 text-gray-600 mr-3" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">•••• 4242</p>
|
||||
<p className="text-xs text-gray-500">Expires 12/25</p>
|
||||
</div>
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
|
||||
Default
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoices */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Invoices</h2>
|
||||
</div>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Invoice
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Period
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Due Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-4 text-center text-gray-500">
|
||||
No invoices yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{invoice.invoice_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(invoice.period_start).toLocaleDateString()} -{' '}
|
||||
{new Date(invoice.period_end).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{invoice.total_amount.toLocaleString()} {invoice.currency}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||
invoice.status
|
||||
)}`}
|
||||
>
|
||||
{invoice.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(invoice.due_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{invoice.pdf_url && (
|
||||
<a
|
||||
href={invoice.pdf_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-900 inline-flex items-center"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
PDF
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Building2,
|
||||
Users,
|
||||
Activity,
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface DashboardStats {
|
||||
tenants: { total: number; active: number; trial: number };
|
||||
users: { total: number; active: number };
|
||||
api_usage: { today: number; this_month: number; limit: number };
|
||||
revenue: { mrr: number; this_month: number; last_month: number };
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
trend,
|
||||
trendUp,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
icon: React.ElementType;
|
||||
trend?: string;
|
||||
trendUp?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900">{value}</p>
|
||||
{trend && (
|
||||
<div className={`mt-1 flex items-center text-sm ${trendUp ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trendUp ? <TrendingUp className="w-4 h-4 mr-1" /> : <TrendingDown className="w-4 h-4 mr-1" />}
|
||||
{trend}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-50 rounded-lg">
|
||||
<Icon className="w-6 h-6 text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
const { data: stats } = useQuery<DashboardStats>({
|
||||
queryKey: ['dashboard', 'summary'],
|
||||
queryFn: () => api.get('/dashboard/summary'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{hasPermission('tenants.read') && (
|
||||
<StatCard
|
||||
title="Active Tenants"
|
||||
value={stats?.tenants.active ?? 0}
|
||||
subtitle={`${stats?.tenants.total ?? 0} total`}
|
||||
icon={Building2}
|
||||
trend="↑ 12%"
|
||||
trendUp
|
||||
/>
|
||||
)}
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats?.users.total ?? 0}
|
||||
subtitle={`${stats?.users.active ?? 0} active`}
|
||||
icon={Users}
|
||||
trend="↑ 8%"
|
||||
trendUp
|
||||
/>
|
||||
<StatCard
|
||||
title="API Calls Today"
|
||||
value={stats?.api_usage.today?.toLocaleString() ?? 0}
|
||||
subtitle={`${stats?.api_usage.this_month?.toLocaleString() ?? 0} this month`}
|
||||
icon={Activity}
|
||||
trend="↑ 23%"
|
||||
trendUp
|
||||
/>
|
||||
{hasPermission('billing.read') && (
|
||||
<StatCard
|
||||
title="Revenue (MTD)"
|
||||
value={`${stats?.revenue.this_month?.toLocaleString() ?? 0} SEK`}
|
||||
subtitle={`MRR: ${stats?.revenue.mrr?.toLocaleString() ?? 0} SEK`}
|
||||
icon={CreditCard}
|
||||
trend="↑ 15%"
|
||||
trendUp
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Recent Activity</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Usage Chart */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">API Usage (7 days)</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<ApiUsageChart />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityFeed() {
|
||||
const activities = [
|
||||
{ action: 'New tenant', detail: 'Stockholm Kommun registered', time: '2 min ago' },
|
||||
{ action: 'User locked', detail: 'admin@example.com after 5 failed attempts', time: '15 min ago' },
|
||||
{ action: 'Invoice paid', detail: '#INV-2024-001 - 12,000 SEK', time: '1 hour ago' },
|
||||
{ action: 'API key revoked', detail: 'Production key by admin@x.com', time: '2 hours ago' },
|
||||
];
|
||||
|
||||
return (
|
||||
<ul className="space-y-4">
|
||||
{activities.map((activity, i) => (
|
||||
<li key={i} className="flex items-start space-x-3">
|
||||
<div className="w-2 h-2 mt-2 bg-indigo-500 rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{activity.action}</p>
|
||||
<p className="text-sm text-gray-500">{activity.detail}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{activity.time}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiUsageChart() {
|
||||
// Placeholder for chart - would use recharts or similar
|
||||
const data = [65, 78, 90, 81, 56, 95, 120];
|
||||
const max = Math.max(...data);
|
||||
|
||||
return (
|
||||
<div className="h-64 flex items-end space-x-2">
|
||||
{data.map((value, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="w-full bg-indigo-500 rounded-t transition-all hover:bg-indigo-600"
|
||||
style={{ height: `${(value / max) * 100}%` }}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-2">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][i]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Puzzle, Settings, Trash2, Check, Download, ExternalLink } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface Plugin {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
version: string;
|
||||
author: string;
|
||||
is_installed: boolean;
|
||||
is_system: boolean;
|
||||
status: 'active' | 'inactive' | 'deprecated';
|
||||
}
|
||||
|
||||
export function PluginManager() {
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
const { data: pluginsData, refetch } = useQuery({
|
||||
queryKey: ['plugins'],
|
||||
queryFn: () => api.get('/plugins'),
|
||||
});
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (pluginId: string) => api.post(`/plugins/${pluginId}/install`),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: (pluginId: string) => api.delete(`/plugins/${pluginId}/uninstall`),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, enable }: { id: string; enable: boolean }) =>
|
||||
api.post(`/plugins/${id}/${enable ? 'enable' : 'disable'}`),
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
|
||||
const plugins: Plugin[] = pluginsData?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Plugins</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center space-x-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
<button className="px-4 py-2 bg-white rounded-md text-sm font-medium shadow-sm">
|
||||
Available
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900">
|
||||
Installed
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900">
|
||||
Updates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plugin Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{plugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="p-3 bg-indigo-50 rounded-lg">
|
||||
<Puzzle className="w-6 h-6 text-indigo-600" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{plugin.is_installed ? (
|
||||
<>
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
|
||||
Installed
|
||||
</span>
|
||||
{plugin.is_system && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full">
|
||||
Available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900">{plugin.name}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">{plugin.description}</p>
|
||||
|
||||
<div className="mt-4 flex items-center text-xs text-gray-500 space-x-4">
|
||||
<span>v{plugin.version}</span>
|
||||
<span>by {plugin.author}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center space-x-2">
|
||||
{!plugin.is_installed ? (
|
||||
hasPermission('plugins.install') && (
|
||||
<button
|
||||
onClick={() => installMutation.mutate(plugin.id)}
|
||||
disabled={installMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Install
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{hasPermission('plugins.configure') && (
|
||||
<button className="flex items-center px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
{!plugin.is_system && hasPermission('plugins.uninstall') && (
|
||||
<button
|
||||
onClick={() => uninstallMutation.mutate(plugin.id)}
|
||||
className="flex items-center px-3 py-2 border border-red-300 text-red-600 rounded-lg text-sm hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Uninstall
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Calendar, FileText, Download, Trash2, Plus } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
schedule: string | null;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ReportRun {
|
||||
id: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
result_url: string | null;
|
||||
error_message: string | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export function ReportBuilder() {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = usePermissions();
|
||||
const [selectedReport, setSelectedReport] = useState<string | null>(null);
|
||||
|
||||
const { data: reportsData } = useQuery({
|
||||
queryKey: ['reports'],
|
||||
queryFn: () => api.get('/reports'),
|
||||
});
|
||||
|
||||
const { data: runsData } = useQuery({
|
||||
queryKey: ['report-runs', selectedReport],
|
||||
queryFn: () => api.get(`/reports/${selectedReport}/runs`),
|
||||
enabled: !!selectedReport,
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (reportId: string) => api.post(`/reports/${reportId}/run`),
|
||||
});
|
||||
|
||||
const reports: Report[] = reportsData?.data ?? [];
|
||||
const runs: ReportRun[] = runsData?.data ?? [];
|
||||
|
||||
if (!hasPermission('reports.read')) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">You don't have permission to view reports.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
|
||||
{hasPermission('reports.create') && (
|
||||
<button
|
||||
onClick={() => navigate('/reports/new')}
|
||||
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Report
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reports List */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
className={`bg-white rounded-lg shadow p-6 cursor-pointer transition-all hover:shadow-md ${
|
||||
selectedReport === report.id ? 'ring-2 ring-indigo-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedReport(report.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="p-2 bg-indigo-50 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
{report.schedule && (
|
||||
<span className="flex items-center text-xs text-gray-500">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
Scheduled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-medium text-gray-900">{report.name}</h3>
|
||||
<p className="text-sm text-gray-500 capitalize">{report.type}</p>
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
Last run: {report.last_run_at
|
||||
? new Date(report.last_run_at).toLocaleDateString()
|
||||
: 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
{selectedReport === report.id && (
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(report.id);
|
||||
}}
|
||||
disabled={runMutation.isPending}
|
||||
className="flex items-center px-3 py-1.5 text-sm bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
Run Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Report Runs */}
|
||||
{selectedReport && runs.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Recent Runs</h2>
|
||||
</div>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Started
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
run.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: run.status === 'failed'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{run.completed_at
|
||||
? `${Math.round(
|
||||
(new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 1000
|
||||
)}s`
|
||||
: 'Running...'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{run.result_url && (
|
||||
<a
|
||||
href={run.result_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-900 inline-flex items-center"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
{run.error_message && (
|
||||
<span className="text-red-600 text-xs">{run.error_message}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Save, X, Plus, Trash2 } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface Permission {
|
||||
resource: string;
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
const AVAILABLE_PERMISSIONS: Permission[] = [
|
||||
{ resource: 'users', actions: ['create', 'read', 'update', 'delete', 'impersonate'] },
|
||||
{ resource: 'tenants', actions: ['create', 'read', 'update', 'delete', 'manage'] },
|
||||
{ resource: 'roles', actions: ['create', 'read', 'update', 'delete', 'assign'] },
|
||||
{ resource: 'api_keys', actions: ['create', 'read', 'revoke', 'rotate'] },
|
||||
{ resource: 'billing', actions: ['read', 'manage', 'invoices', 'subscribe'] },
|
||||
{ resource: 'audit', actions: ['read', 'export'] },
|
||||
{ resource: 'reports', actions: ['create', 'read', 'run', 'export', 'delete'] },
|
||||
{ resource: 'plugins', actions: ['install', 'configure', 'uninstall', 'enable'] },
|
||||
{ resource: 'settings', actions: ['read', 'update'] },
|
||||
];
|
||||
|
||||
export function RoleEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isNew = id === 'new';
|
||||
const { hasPermission } = usePermissions();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: role, isLoading } = useQuery({
|
||||
queryKey: ['role', id],
|
||||
queryFn: () => api.get(`/roles/${id}`),
|
||||
enabled: !isNew,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (role) {
|
||||
setName(role.name);
|
||||
setSlug(role.slug);
|
||||
setDescription(role.description || '');
|
||||
setSelectedPermissions(new Set(role.permissions));
|
||||
}
|
||||
}, [role]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: unknown) =>
|
||||
isNew ? api.post('/roles', data) : api.patch(`/roles/${id}`, data),
|
||||
onSuccess: () => {
|
||||
navigate('/roles');
|
||||
},
|
||||
});
|
||||
|
||||
const togglePermission = (permission: string) => {
|
||||
const newPerms = new Set(selectedPermissions);
|
||||
if (newPerms.has(permission)) {
|
||||
newPerms.delete(permission);
|
||||
} else {
|
||||
newPerms.add(permission);
|
||||
}
|
||||
setSelectedPermissions(newPerms);
|
||||
};
|
||||
|
||||
const toggleAllForResource = (resource: string, actions: string[]) => {
|
||||
const newPerms = new Set(selectedPermissions);
|
||||
const allSelected = actions.every((a) => newPerms.has(`${resource}.${a}`));
|
||||
|
||||
actions.forEach((action) => {
|
||||
const perm = `${resource}.${action}`;
|
||||
if (allSelected) {
|
||||
newPerms.delete(perm);
|
||||
} else {
|
||||
newPerms.add(perm);
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedPermissions(newPerms);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate({
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
permissions: Array.from(selectedPermissions),
|
||||
});
|
||||
};
|
||||
|
||||
if (!hasPermission('roles.create') && isNew) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">You don't have permission to create roles.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isNew ? 'Create Role' : 'Edit Role'}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => navigate('/roles')}
|
||||
className="flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Basic Information</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="e.g., Content Manager"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="e.g., content-manager"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Describe what this role is for..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Permissions</h2>
|
||||
<div className="space-y-6">
|
||||
{AVAILABLE_PERMISSIONS.map((perm) => (
|
||||
<div key={perm.resource} className="border-b border-gray-100 pb-4 last:border-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900 capitalize">
|
||||
{perm.resource}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => toggleAllForResource(perm.resource, perm.actions)}
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Toggle All
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{perm.actions.map((action) => {
|
||||
const permKey = `${perm.resource}.${action}`;
|
||||
const isSelected = selectedPermissions.has(permKey);
|
||||
return (
|
||||
<label
|
||||
key={permKey}
|
||||
className={`flex items-center px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => togglePermission(permKey)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm capitalize">{action}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
{!isNew && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Members</h2>
|
||||
<button className="flex items-center px-3 py-1.5 text-sm bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add Member
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">12 users have this role</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Search, Filter, Download } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: 'municipality' | 'enterprise' | 'partner';
|
||||
status: 'active' | 'suspended' | 'cancelled' | 'trial';
|
||||
org_number: string;
|
||||
user_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function TenantList() {
|
||||
const { hasPermission } = usePermissions();
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['tenants', { search, type: typeFilter, status: statusFilter }],
|
||||
queryFn: () =>
|
||||
api.get('/tenants', {
|
||||
params: { search, type: typeFilter, status: statusFilter },
|
||||
}),
|
||||
});
|
||||
|
||||
const tenants: Tenant[] = data?.data ?? [];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'trial':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'suspended':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'municipality':
|
||||
return 'Municipality';
|
||||
case 'enterprise':
|
||||
return 'Enterprise';
|
||||
case 'partner':
|
||||
return 'Partner';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
|
||||
{hasPermission('tenants.create') && (
|
||||
<Link
|
||||
to="/tenants/new"
|
||||
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Tenant
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4 bg-white p-4 rounded-lg shadow">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tenants..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="municipality">Municipality</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="partner">Partner</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="trial">Trial</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<button className="flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Org Number
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Users
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : tenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
|
||||
No tenants found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenants.map((tenant) => (
|
||||
<tr key={tenant.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{tenant.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{tenant.slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-900">
|
||||
{getTypeLabel(tenant.type)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{tenant.org_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{tenant.user_count}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||
tenant.status
|
||||
)}`}
|
||||
>
|
||||
{tenant.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(tenant.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Search, Mail, Lock, Unlock } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
status: 'active' | 'inactive' | 'pending' | 'locked';
|
||||
roles: { name: string }[];
|
||||
last_login_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function UserList() {
|
||||
const { hasPermission } = usePermissions();
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['users', { search, role: roleFilter, status: statusFilter }],
|
||||
queryFn: () =>
|
||||
api.get('/users', {
|
||||
params: { search, role: roleFilter, status: statusFilter },
|
||||
}),
|
||||
});
|
||||
|
||||
const users: User[] = data?.data ?? [];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'locked':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
|
||||
{hasPermission('users.create') && (
|
||||
<Link
|
||||
to="/users/invite"
|
||||
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Invite User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center space-x-4 bg-white p-4 rounded-lg shadow">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="locked">Locked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Roles
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center mr-3">
|
||||
<span className="text-sm font-medium text-indigo-600">
|
||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{user.first_name} {user.last_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded"
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||
user.status
|
||||
)}`}
|
||||
>
|
||||
{user.status === 'locked' && <Lock className="w-3 h-3 mr-1" />}
|
||||
{user.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.last_login_at
|
||||
? new Date(user.last_login_at).toLocaleString()
|
||||
: 'Never'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{hasPermission('users.update') && (
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('users.update') && user.status === 'locked' && (
|
||||
<button className="text-green-600 hover:text-green-900">
|
||||
<Unlock className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
avatar_url?: string;
|
||||
roles: { id: string; name: string; permissions: string[] }[];
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
interface Tokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
tokens: Tokens | null;
|
||||
setAuth: (user: User | null, tokens: Tokens | null) => void;
|
||||
clearAuth: () => void;
|
||||
updateUser: (user: Partial<User>) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
tokens: null,
|
||||
setAuth: (user, tokens) => set({ user, tokens }),
|
||||
clearAuth: () => set({ user: null, tokens: null }),
|
||||
updateUser: (updates) =>
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, ...updates } : null,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'landvex-auth',
|
||||
partialize: (state) => ({ tokens: state.tokens }),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user