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:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
+21
View File
@@ -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;"]
+40
View File
@@ -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"
}
}
+73
View File
@@ -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,
};
}
+87
View File
@@ -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 }),
}
)
);