feat: implement naming convention, deployment automation, and infrastructure updates

- Add comprehensive naming convention (provider-region-resource-env-purpose)
- Implement Terraform locals for centralized naming
- Update all Terraform resources to use new naming convention
- Create deployment automation framework (18 phase scripts)
- Add Azure setup scripts (provider registration, quota checks)
- Update deployment scripts config with naming functions
- Create complete deployment documentation (guide, steps, quick reference)
- Add frontend portal implementations (public and internal)
- Add UI component library (18 components)
- Enhance Entra VerifiedID integration with file utilities
- Add API client package for all services
- Create comprehensive documentation (naming, deployment, next steps)

Infrastructure:
- Resource groups, storage accounts with new naming
- Terraform configuration updates
- Outputs with naming convention examples

Deployment:
- Automated deployment scripts for all 15 phases
- State management and logging
- Error handling and validation

Documentation:
- Naming convention guide and implementation summary
- Complete deployment guide (296 steps)
- Next steps and quick start guides
- Azure prerequisites and setup completion docs

Note: ESLint warnings present - will be addressed in follow-up commit
This commit is contained in:
defiQUG
2025-11-12 08:22:51 -08:00
parent 9e46f3f316
commit 8649ad4124
136 changed files with 17251 additions and 147 deletions

View File

@@ -0,0 +1,196 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Select, Button, Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Skeleton } from '@the-order/ui';
import { getApiClient } from '@the-order/api-client';
export default function AuditPage() {
const apiClient = getApiClient();
const [filters, setFilters] = useState({
action: '',
credentialId: '',
subjectDid: '',
page: 1,
pageSize: 50,
});
const { data: auditLogs, isLoading, error } = useQuery({
queryKey: ['audit-logs', filters],
queryFn: () =>
apiClient.identity.searchAuditLogs({
action: filters.action as 'issued' | 'revoked' | 'verified' | 'renewed' | undefined,
credentialId: filters.credentialId || undefined,
subjectDid: filters.subjectDid || undefined,
page: filters.page,
pageSize: filters.pageSize,
}),
});
const getActionBadge = (action: string) => {
switch (action) {
case 'issued':
return <Badge variant="success">Issued</Badge>;
case 'revoked':
return <Badge variant="destructive">Revoked</Badge>;
case 'verified':
return <Badge variant="default">Verified</Badge>;
case 'renewed':
return <Badge variant="default">Renewed</Badge>;
default:
return <Badge>{action}</Badge>;
}
};
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Audit Logs</h1>
<p className="text-gray-600">Search and view audit logs for credential operations</p>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Filters</CardTitle>
<CardDescription>Filter audit logs by various criteria</CardDescription>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-4 gap-4">
<div>
<Label htmlFor="action">Action</Label>
<Select
id="action"
value={filters.action}
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
>
<option value="">All Actions</option>
<option value="issued">Issued</option>
<option value="revoked">Revoked</option>
<option value="verified">Verified</option>
<option value="renewed">Renewed</option>
</Select>
</div>
<div>
<Label htmlFor="credentialId">Credential ID</Label>
<Input
id="credentialId"
placeholder="Enter credential ID"
value={filters.credentialId}
onChange={(e) => setFilters({ ...filters, credentialId: e.target.value })}
/>
</div>
<div>
<Label htmlFor="subjectDid">Subject DID</Label>
<Input
id="subjectDid"
placeholder="Enter subject DID"
value={filters.subjectDid}
onChange={(e) => setFilters({ ...filters, subjectDid: e.target.value })}
/>
</div>
<div className="flex items-end">
<Button
onClick={() =>
setFilters({
action: '',
credentialId: '',
subjectDid: '',
page: 1,
pageSize: 50,
})
}
variant="outline"
className="w-full"
>
Clear Filters
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Audit Log Entries</CardTitle>
<CardDescription>
{auditLogs?.total ? `${auditLogs.total} total entries` : 'No entries found'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : error ? (
<p className="text-red-600 text-center py-8">
{error instanceof Error ? error.message : 'Failed to load audit logs'}
</p>
) : auditLogs?.logs && auditLogs.logs.length > 0 ? (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Action</TableHead>
<TableHead>Credential ID</TableHead>
<TableHead>Subject DID</TableHead>
<TableHead>Performed By</TableHead>
<TableHead>IP Address</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditLogs.logs.map((log: any) => (
<TableRow key={log.id || `${log.credential_id}-${log.performed_at}`}>
<TableCell>
{log.performed_at
? new Date(log.performed_at).toLocaleString()
: 'N/A'}
</TableCell>
<TableCell>{getActionBadge(log.action || 'unknown')}</TableCell>
<TableCell className="font-mono text-sm">
{log.credential_id || 'N/A'}
</TableCell>
<TableCell className="font-mono text-sm">{log.subject_did || 'N/A'}</TableCell>
<TableCell>{log.performed_by || 'System'}</TableCell>
<TableCell className="font-mono text-sm">{log.ip_address || 'N/A'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-gray-600">
Showing {auditLogs.logs.length} of {auditLogs.total} entries
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => setFilters({ ...filters, page: filters.page - 1 })}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={auditLogs.logs.length < filters.pageSize}
onClick={() => setFilters({ ...filters, page: filters.page + 1 })}
>
Next
</Button>
</div>
</div>
</>
) : (
<p className="text-center text-gray-600 py-8">No audit log entries found</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Button,
Select,
Textarea,
useToast,
Alert,
AlertDescription,
} from '@the-order/ui';
import { getApiClient } from '@the-order/api-client';
export default function IssueCredentialPage() {
const router = useRouter();
const apiClient = getApiClient();
const { success, error: showError } = useToast();
const [formData, setFormData] = useState({
subjectDid: '',
credentialType: 'eResident' as 'eResident' | 'eCitizen',
expirationDate: '',
givenName: '',
familyName: '',
email: '',
dateOfBirth: '',
nationality: '',
notes: '',
});
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const credentialSubject: Record<string, unknown> = {
givenName: data.givenName,
familyName: data.familyName,
email: data.email,
};
if (data.dateOfBirth) {
credentialSubject.dateOfBirth = data.dateOfBirth;
}
if (data.nationality) {
credentialSubject.nationality = data.nationality;
}
return apiClient.identity.issueCredential({
subject: data.subjectDid,
credentialSubject,
expirationDate: data.expirationDate || undefined,
});
},
onSuccess: (data) => {
success('Credential issued successfully', `Credential ID: ${data.credential.id}`);
router.push('/credentials');
},
onError: (error) => {
showError(
error instanceof Error ? error.message : 'Failed to issue credential',
'Issuance Error'
);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.subjectDid.trim()) {
showError('Subject DID is required', 'Validation Error');
return;
}
if (!formData.givenName.trim() || !formData.familyName.trim()) {
showError('Given name and family name are required', 'Validation Error');
return;
}
mutation.mutate(formData);
};
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4 max-w-3xl">
<div className="mb-6">
<Button variant="outline" onClick={() => router.push('/credentials')}>
Back to Credentials
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Issue New Credential</CardTitle>
<CardDescription>
Create and issue a new verifiable credential to a subject
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<Label htmlFor="subjectDid">Subject DID *</Label>
<Input
id="subjectDid"
required
value={formData.subjectDid}
onChange={(e) => setFormData({ ...formData, subjectDid: e.target.value })}
placeholder="did:example:123456789"
className="mt-2"
/>
<p className="text-sm text-gray-500 mt-1">
The Decentralized Identifier (DID) of the credential subject
</p>
</div>
<div>
<Label htmlFor="credentialType">Credential Type *</Label>
<Select
id="credentialType"
required
value={formData.credentialType}
onChange={(e) =>
setFormData({
...formData,
credentialType: e.target.value as 'eResident' | 'eCitizen',
})
}
className="mt-2"
>
<option value="eResident">eResident</option>
<option value="eCitizen">eCitizen</option>
</Select>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="givenName">Given Name *</Label>
<Input
id="givenName"
required
value={formData.givenName}
onChange={(e) => setFormData({ ...formData, givenName: e.target.value })}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="familyName">Family Name *</Label>
<Input
id="familyName"
required
value={formData.familyName}
onChange={(e) => setFormData({ ...formData, familyName: e.target.value })}
className="mt-2"
/>
</div>
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="mt-2"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor="dateOfBirth">Date of Birth</Label>
<Input
id="dateOfBirth"
type="date"
value={formData.dateOfBirth}
onChange={(e) => setFormData({ ...formData, dateOfBirth: e.target.value })}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="nationality">Nationality</Label>
<Input
id="nationality"
value={formData.nationality}
onChange={(e) => setFormData({ ...formData, nationality: e.target.value })}
className="mt-2"
/>
</div>
</div>
<div>
<Label htmlFor="expirationDate">Expiration Date</Label>
<Input
id="expirationDate"
type="date"
value={formData.expirationDate}
onChange={(e) => setFormData({ ...formData, expirationDate: e.target.value })}
className="mt-2"
/>
<p className="text-sm text-gray-500 mt-1">
Leave empty for credentials that don't expire
</p>
</div>
<div>
<Label htmlFor="notes">Notes (Internal)</Label>
<Textarea
id="notes"
rows={3}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="mt-2"
placeholder="Internal notes about this credential issuance..."
/>
</div>
{mutation.isError && (
<Alert variant="destructive">
<AlertDescription>
{mutation.error instanceof Error
? mutation.error.message
: 'An error occurred while issuing the credential'}
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-4">
<Button variant="outline" type="button" onClick={() => router.push('/credentials')}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Issuing...' : 'Issue Credential'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Button, Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Skeleton } from '@the-order/ui';
import { getApiClient } from '@the-order/api-client';
export default function CredentialsPage() {
const router = useRouter();
const apiClient = getApiClient();
const [searchTerm, setSearchTerm] = useState('');
// This would typically fetch from an endpoint that lists credentials
// For now, we'll use a placeholder query
const { data: metrics, isLoading } = useQuery({
queryKey: ['credentials-list'],
queryFn: async () => {
// Placeholder - in production, this would be a dedicated endpoint
const dashboard = await apiClient.identity.getMetricsDashboard();
return dashboard.summary.recentIssuances;
},
});
const filteredData = metrics?.filter((item) =>
item.credentialId.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.credentialType.some((type) => type.toLowerCase().includes(searchTerm.toLowerCase()))
);
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Credential Management</h1>
<p className="text-gray-600">Manage and view verifiable credentials</p>
</div>
<Button onClick={() => router.push('/credentials/issue')}>Issue New Credential</Button>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Credentials</CardTitle>
<CardDescription>View and manage issued credentials</CardDescription>
</div>
<div className="w-64">
<Input
placeholder="Search credentials..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : filteredData && filteredData.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Credential ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Subject DID</TableHead>
<TableHead>Issued At</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((credential) => (
<TableRow key={credential.credentialId}>
<TableCell className="font-mono text-sm">{credential.credentialId}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{credential.credentialType.map((type) => (
<Badge key={type} variant="secondary">
{type}
</Badge>
))}
</div>
</TableCell>
<TableCell className="font-mono text-sm">{credential.subjectDid}</TableCell>
<TableCell>{new Date(credential.issuedAt).toLocaleString()}</TableCell>
<TableCell>
<Badge variant="success">Active</Badge>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button variant="outline" size="sm">
View
</Button>
<Button variant="destructive" size="sm">
Revoke
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-center text-gray-600 py-8">No credentials found</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,5 +1,8 @@
import type { Metadata } from 'next';
import { ReactNode } from 'react';
import './globals.css';
import { Providers } from '../lib/providers';
import { Header } from '../components/Header';
export const metadata: Metadata = {
title: 'The Order - Internal Portal',
@@ -13,7 +16,12 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body>{children}</body>
<body className="flex flex-col min-h-screen">
<Providers>
<Header />
<main className="flex-1">{children}</main>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Button, Alert, AlertDescription } from '@the-order/ui';
import { useAuth } from '../../lib/auth';
import { useToast } from '@the-order/ui';
export default function LoginPage() {
const router = useRouter();
const { login } = useAuth();
const { success, error: showError } = useToast();
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setLoginError(null);
try {
// In production, this would call an authentication API
// For now, we'll simulate a login
if (formData.email && formData.password) {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// Mock authentication - in production, this would be a real API call
// Admin users should have admin role
const mockUser = {
id: 'admin-123',
email: formData.email,
name: formData.email.split('@')[0],
accessToken: 'mock-access-token-' + Date.now(),
roles: ['admin', 'reviewer'],
};
login(mockUser);
success('Login successful', 'Welcome to the admin portal!');
router.push('/');
} else {
throw new Error('Please enter email and password');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed';
setLoginError(errorMessage);
showError(errorMessage, 'Login Error');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Admin Login</CardTitle>
<CardDescription>Sign in to the internal portal</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{loginError && (
<Alert variant="destructive">
<AlertDescription>{loginError}</AlertDescription>
</Alert>
)}
<div>
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="admin@theorder.org"
className="mt-2"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="Enter your password"
className="mt-2"
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
<div className="text-center text-sm text-gray-600">
<p className="mb-2">For security, this portal requires authentication.</p>
<a href="/forgot-password" className="text-primary hover:underline">
Forgot password?
</a>
</div>
</form>
<div className="mt-6 pt-6 border-t">
<p className="text-sm text-gray-600 text-center mb-4">Or continue with</p>
<div className="space-y-2">
<Button variant="outline" className="w-full" type="button">
OIDC / eIDAS
</Button>
<Button variant="outline" className="w-full" type="button">
DID Wallet
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,189 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Badge, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@the-order/ui';
import { getApiClient } from '@the-order/api-client';
export default function MetricsPage() {
const apiClient = getApiClient();
const { data: dashboard, isLoading, error } = useQuery({
queryKey: ['metrics-dashboard'],
queryFn: () => apiClient.identity.getMetricsDashboard(),
});
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Metrics & Analytics</h1>
<p className="text-gray-600">Credential issuance metrics and analytics</p>
</div>
<div className="grid md:grid-cols-4 gap-6">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<Skeleton className="h-8 w-24 mb-2" />
<Skeleton className="h-4 w-32" />
</CardContent>
</Card>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<Card>
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>Failed to load metrics</CardDescription>
</CardHeader>
<CardContent>
<p className="text-red-600">{error instanceof Error ? error.message : 'Unknown error'}</p>
</CardContent>
</Card>
</div>
</div>
);
}
const summary = dashboard?.summary;
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Metrics & Analytics</h1>
<p className="text-gray-600">Credential issuance metrics and analytics</p>
</div>
<div className="grid md:grid-cols-4 gap-6 mb-8">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-gray-500">Issued Today</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary?.issuedToday || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-gray-500">Issued This Week</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary?.issuedThisWeek || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-gray-500">Issued This Month</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary?.issuedThisMonth || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-gray-500">Success Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary?.successRate.toFixed(1) || 0}%</div>
</CardContent>
</Card>
</div>
<div className="grid md:grid-cols-2 gap-6 mb-8">
<Card>
<CardHeader>
<CardTitle>Top Credential Types</CardTitle>
<CardDescription>Most issued credential types</CardDescription>
</CardHeader>
<CardContent>
{dashboard?.topCredentialTypes && dashboard.topCredentialTypes.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Count</TableHead>
<TableHead>Percentage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboard.topCredentialTypes.map((item) => (
<TableRow key={item.type}>
<TableCell>{item.type}</TableCell>
<TableCell>{item.count}</TableCell>
<TableCell>{item.percentage.toFixed(1)}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-gray-600 text-center py-4">No data available</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Issuances</CardTitle>
<CardDescription>Latest credential issuances</CardDescription>
</CardHeader>
<CardContent>
{summary?.recentIssuances && summary.recentIssuances.length > 0 ? (
<div className="space-y-4">
{summary.recentIssuances.map((issuance) => (
<div key={issuance.credentialId} className="flex items-center justify-between border-b pb-3">
<div>
<p className="font-medium">{issuance.credentialType.join(', ')}</p>
<p className="text-sm text-gray-600">{issuance.credentialId}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">
{new Date(issuance.issuedAt).toLocaleDateString()}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-600 text-center py-4">No recent issuances</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Credential Type Distribution</CardTitle>
<CardDescription>Breakdown by credential type</CardDescription>
</CardHeader>
<CardContent>
{summary?.byCredentialType && Object.keys(summary.byCredentialType).length > 0 ? (
<div className="space-y-2">
{Object.entries(summary.byCredentialType).map(([type, count]) => (
<div key={type} className="flex items-center justify-between">
<span className="text-sm font-medium">{type}</span>
<Badge>{count}</Badge>
</div>
))}
</div>
) : (
<p className="text-gray-600 text-center py-4">No data available</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,9 +1,89 @@
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
export default function Home() {
return (
<main>
<h1>The Order - Internal Portal</h1>
<p>Welcome to The Order internal portal (admin/ops).</p>
</main>
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-16">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">Admin Dashboard</h1>
<p className="text-gray-600">Internal operations and management portal for The Order</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle>Application Review</CardTitle>
<CardDescription>Review and adjudicate eResidency applications</CardDescription>
</CardHeader>
<CardContent>
<Link href="/review" className="text-primary hover:underline font-medium">
Review Applications
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Credential Management</CardTitle>
<CardDescription>Manage verifiable credentials and issuance</CardDescription>
</CardHeader>
<CardContent>
<Link href="/credentials" className="text-primary hover:underline font-medium">
Manage Credentials
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Metrics & Analytics</CardTitle>
<CardDescription>View metrics and analytics dashboard</CardDescription>
</CardHeader>
<CardContent>
<Link href="/metrics" className="text-primary hover:underline font-medium">
View Metrics
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Audit Logs</CardTitle>
<CardDescription>View and search audit logs</CardDescription>
</CardHeader>
<CardContent>
<Link href="/audit" className="text-primary hover:underline font-medium">
View Logs
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>User Management</CardTitle>
<CardDescription>Manage users and permissions</CardDescription>
</CardHeader>
<CardContent>
<Link href="/users" className="text-primary hover:underline font-medium">
Manage Users
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>System Settings</CardTitle>
<CardDescription>Configure system settings and preferences</CardDescription>
</CardHeader>
<CardContent>
<Link href="/settings" className="text-primary hover:underline font-medium">
Settings
</Link>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Badge, Alert, AlertDescription, Textarea, Label, useToast } from '@the-order/ui';
import { getApiClient } from '@the-order/api-client';
import { useState } from 'react';
export default function ReviewDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const apiClient = getApiClient();
const applicationId = params.id as string;
const [decision, setDecision] = useState<'approve' | 'reject' | null>(null);
const [notes, setNotes] = useState('');
const [reason, setReason] = useState('');
const { success, error: showError } = useToast();
const { data: application, isLoading, error } = useQuery({
queryKey: ['application', applicationId],
queryFn: () => apiClient.eresidency.getApplicationForReview(applicationId),
});
const adjudicateMutation = useMutation({
mutationFn: async (data: { decision: 'approve' | 'reject'; reason?: string; notes?: string }) => {
return apiClient.eresidency.adjudicateApplication(applicationId, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application', applicationId] });
queryClient.invalidateQueries({ queryKey: ['review-queue'] });
success('Decision submitted successfully', 'The application has been adjudicated.');
router.push('/review');
},
onError: (error) => {
showError(
error instanceof Error ? error.message : 'Failed to submit decision',
'Error'
);
},
});
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4 max-w-4xl">
<Card>
<CardContent className="py-12 text-center">
<p className="text-gray-600">Loading application...</p>
</CardContent>
</Card>
</div>
</div>
);
}
if (error || !application) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4 max-w-4xl">
<Card>
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>Failed to load application</CardDescription>
</CardHeader>
<CardContent>
<p className="text-red-600">{error instanceof Error ? error.message : 'Application not found'}</p>
<Button onClick={() => router.push('/review')} className="mt-4">
Back to Queue
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'approved':
return <Badge variant="success">Approved</Badge>;
case 'rejected':
return <Badge variant="destructive">Rejected</Badge>;
case 'under_review':
return <Badge variant="default">Under Review</Badge>;
case 'kyc_pending':
return <Badge variant="warning">KYC Pending</Badge>;
default:
return <Badge>{status}</Badge>;
}
};
const handleAdjudicate = () => {
if (!decision) return;
if (decision === 'reject' && !reason.trim()) {
alert('Please provide a rejection reason');
return;
}
adjudicateMutation.mutate({
decision,
reason: decision === 'reject' ? reason : undefined,
notes: notes.trim() || undefined,
});
};
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4 max-w-4xl">
<div className="mb-6">
<Button variant="outline" onClick={() => router.push('/review')}>
Back to Queue
</Button>
</div>
<div className="grid gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>
{application.givenName} {application.familyName}
</CardTitle>
<CardDescription>Application ID: {application.id}</CardDescription>
</div>
{getStatusBadge(application.status)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid md:grid-cols-2 gap-6">
<div>
<Label className="text-sm font-medium text-gray-500">Email</Label>
<p className="mt-1 text-gray-900">{application.email}</p>
</div>
<div>
<Label className="text-sm font-medium text-gray-500">Phone</Label>
<p className="mt-1 text-gray-900">{application.phone || 'N/A'}</p>
</div>
<div>
<Label className="text-sm font-medium text-gray-500">Date of Birth</Label>
<p className="mt-1 text-gray-900">{application.dateOfBirth || 'N/A'}</p>
</div>
<div>
<Label className="text-sm font-medium text-gray-500">Nationality</Label>
<p className="mt-1 text-gray-900">{application.nationality || 'N/A'}</p>
</div>
</div>
{application.address && (
<div>
<Label className="text-sm font-medium text-gray-500">Address</Label>
<p className="mt-1 text-gray-900">
{[
application.address.street,
application.address.city,
application.address.region,
application.address.postalCode,
application.address.country,
]
.filter(Boolean)
.join(', ')}
</p>
</div>
)}
{application.riskScore !== undefined && (
<div>
<Label className="text-sm font-medium text-gray-500">Risk Score</Label>
<p className="mt-1 text-gray-900">{(application.riskScore * 100).toFixed(1)}%</p>
</div>
)}
{application.kycStatus && (
<div>
<Label className="text-sm font-medium text-gray-500">KYC Status</Label>
<p className="mt-1 text-gray-900">{application.kycStatus}</p>
</div>
)}
{application.sanctionsStatus && (
<div>
<Label className="text-sm font-medium text-gray-500">Sanctions Status</Label>
<p className="mt-1 text-gray-900">{application.sanctionsStatus}</p>
</div>
)}
{application.submittedAt && (
<div>
<Label className="text-sm font-medium text-gray-500">Submitted At</Label>
<p className="mt-1 text-gray-900">{new Date(application.submittedAt).toLocaleString()}</p>
</div>
)}
</CardContent>
</Card>
{application.status === 'under_review' && (
<Card>
<CardHeader>
<CardTitle>Adjudication</CardTitle>
<CardDescription>Review and make a decision on this application</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex gap-4">
<Button
variant={decision === 'approve' ? 'default' : 'outline'}
onClick={() => setDecision('approve')}
>
Approve
</Button>
<Button
variant={decision === 'reject' ? 'destructive' : 'outline'}
onClick={() => setDecision('reject')}
>
Reject
</Button>
</div>
{decision === 'reject' && (
<div>
<Label htmlFor="reason">Rejection Reason *</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Please provide a reason for rejection..."
className="mt-2"
rows={3}
/>
</div>
)}
<div>
<Label htmlFor="notes">Notes (Optional)</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Additional notes about this application..."
className="mt-2"
rows={3}
/>
</div>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => router.push('/review')}>
Cancel
</Button>
<Button
onClick={handleAdjudicate}
disabled={adjudicateMutation.isPending || !decision || (decision === 'reject' && !reason.trim())}
>
{adjudicateMutation.isPending ? 'Submitting...' : 'Submit Decision'}
</Button>
</div>
</CardContent>
</Card>
)}
{application.rejectionReason && (
<Alert variant="destructive">
<AlertDescription>
<strong>Rejection Reason:</strong> {application.rejectionReason}
</AlertDescription>
</Alert>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
import { getApiClient } from '@the-order/api-client';
export default function ReviewPage() {
const apiClient = getApiClient();
const { data: queue, isLoading, error } = useQuery({
queryKey: ['review-queue'],
queryFn: () => apiClient.eresidency.getReviewQueue({ limit: 50 }),
});
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<Card>
<CardContent className="py-12 text-center">
<p className="text-gray-600">Loading review queue...</p>
</CardContent>
</Card>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<Card>
<CardHeader>
<CardTitle>Error</CardTitle>
<CardDescription>Failed to load review queue</CardDescription>
</CardHeader>
<CardContent>
<p className="text-red-600">{error instanceof Error ? error.message : 'Unknown error'}</p>
</CardContent>
</Card>
</div>
</div>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'under_review':
return 'text-blue-600 bg-blue-50';
case 'kyc_pending':
return 'text-yellow-600 bg-yellow-50';
case 'approved':
return 'text-green-600 bg-green-50';
case 'rejected':
return 'text-red-600 bg-red-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Application Review Queue</h1>
<p className="text-gray-600">Review and adjudicate eResidency applications</p>
</div>
<Card>
<CardHeader>
<CardTitle>Applications</CardTitle>
<CardDescription>
{queue?.total || 0} application{queue?.total !== 1 ? 's' : ''} in queue
</CardDescription>
</CardHeader>
<CardContent>
{!queue || queue.applications.length === 0 ? (
<p className="text-gray-600 text-center py-8">No applications in queue</p>
) : (
<div className="space-y-4">
{queue.applications.map((app) => (
<Link key={app.id} href={`/review/${app.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg">
{app.givenName} {app.familyName}
</h3>
<p className="text-sm text-gray-600">{app.email}</p>
<p className="text-xs text-gray-500 mt-1">
Submitted: {app.submittedAt ? new Date(app.submittedAt).toLocaleString() : 'N/A'}
</p>
</div>
<div className="text-right">
<div className={`px-3 py-1 rounded-md inline-block ${getStatusColor(app.status)}`}>
{app.status.toUpperCase().replace('_', ' ')}
</div>
{app.riskScore !== undefined && (
<p className="text-sm text-gray-600 mt-2">
Risk Score: {(app.riskScore * 100).toFixed(1)}%
</p>
)}
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Button, Switch, useToast } from '@the-order/ui';
export default function SettingsPage() {
const { success } = useToast();
const [settings, setSettings] = useState({
siteName: 'The Order',
maintenanceMode: false,
allowRegistrations: true,
requireEmailVerification: true,
maxApplicationsPerDay: 10,
apiRateLimit: 100,
});
const handleSave = () => {
// In production, this would save to an API
success('Settings saved successfully', 'Your changes have been applied.');
};
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4 max-w-4xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">System Settings</h1>
<p className="text-gray-600">Configure system-wide settings and preferences</p>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>Basic system configuration</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="siteName">Site Name</Label>
<Input
id="siteName"
value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
className="mt-2"
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="maintenanceMode">Maintenance Mode</Label>
<p className="text-sm text-gray-600">Enable to put the site in maintenance mode</p>
</div>
<Switch
id="maintenanceMode"
checked={settings.maintenanceMode}
onChange={(e) => setSettings({ ...settings, maintenanceMode: e.target.checked })}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Application Settings</CardTitle>
<CardDescription>Configure application submission rules</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="allowRegistrations">Allow New Registrations</Label>
<p className="text-sm text-gray-600">Enable or disable new user registrations</p>
</div>
<Switch
id="allowRegistrations"
checked={settings.allowRegistrations}
onChange={(e) => setSettings({ ...settings, allowRegistrations: e.target.checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireEmailVerification">Require Email Verification</Label>
<p className="text-sm text-gray-600">Users must verify their email before applying</p>
</div>
<Switch
id="requireEmailVerification"
checked={settings.requireEmailVerification}
onChange={(e) =>
setSettings({ ...settings, requireEmailVerification: e.target.checked })
}
/>
</div>
<div>
<Label htmlFor="maxApplicationsPerDay">Max Applications Per Day</Label>
<Input
id="maxApplicationsPerDay"
type="number"
value={settings.maxApplicationsPerDay}
onChange={(e) =>
setSettings({ ...settings, maxApplicationsPerDay: parseInt(e.target.value) || 0 })
}
className="mt-2"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Settings</CardTitle>
<CardDescription>Configure API rate limits and access</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="apiRateLimit">API Rate Limit (requests per minute)</Label>
<Input
id="apiRateLimit"
type="number"
value={settings.apiRateLimit}
onChange={(e) =>
setSettings({ ...settings, apiRateLimit: parseInt(e.target.value) || 0 })
}
className="mt-2"
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave}>Save Settings</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Button, Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Skeleton, Dropdown } from '@the-order/ui';
import { getApiClient } from '@the-order/api-client';
export default function UsersPage() {
const apiClient = getApiClient();
const [searchTerm, setSearchTerm] = useState('');
// Mock data - in production, this would fetch from an API
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: async () => {
// Placeholder - would be a real API call
return [
{ id: '1', email: 'admin@theorder.org', name: 'Admin User', role: 'admin', status: 'active' },
{ id: '2', email: 'reviewer@theorder.org', name: 'Reviewer User', role: 'reviewer', status: 'active' },
{ id: '3', email: 'user@example.com', name: 'Regular User', role: 'user', status: 'inactive' },
];
},
});
const filteredUsers = users?.filter(
(user) =>
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">User Management</h1>
<p className="text-gray-600">Manage users and their permissions</p>
</div>
<Button>Add User</Button>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Users</CardTitle>
<CardDescription>Manage system users and their roles</CardDescription>
</div>
<div className="w-64">
<Input
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : filteredUsers && filteredUsers.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === 'active' ? 'success' : 'warning'}>
{user.status}
</Badge>
</TableCell>
<TableCell>
<Dropdown
trigger={<Button variant="outline" size="sm">Actions</Button>}
items={[
{ label: 'Edit', value: 'edit', onClick: () => console.log('Edit', user.id) },
{ label: 'Reset Password', value: 'reset', onClick: () => console.log('Reset', user.id) },
{ divider: true },
{
label: user.status === 'active' ? 'Deactivate' : 'Activate',
value: 'toggle',
onClick: () => console.log('Toggle', user.id),
},
]}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-center text-gray-600 py-8">No users found</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '../lib/auth';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-gray-600">Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@the-order/ui';
import { useAuth } from '../lib/auth';
export function Header() {
const router = useRouter();
const { isAuthenticated, user, logout } = useAuth();
const handleLogout = () => {
logout();
router.push('/login');
};
return (
<header className="border-b bg-white">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link href="/" className="text-2xl font-bold text-gray-900">
The Order - Internal
</Link>
<nav className="flex items-center gap-4">
<Link href="/review" className="text-gray-600 hover:text-gray-900">
Review
</Link>
<Link href="/credentials" className="text-gray-600 hover:text-gray-900">
Credentials
</Link>
<Link href="/metrics" className="text-gray-600 hover:text-gray-900">
Metrics
</Link>
<Link href="/audit" className="text-gray-600 hover:text-gray-900">
Audit
</Link>
{isAuthenticated ? (
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{user?.email || user?.name || 'Admin'}</span>
<Button variant="outline" size="sm" onClick={handleLogout}>
Logout
</Button>
</div>
) : (
<Link href="/login">
<Button variant="outline" size="sm">Login</Button>
</Link>
)}
</nav>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
import React from 'react';
// Simple auth store without external dependencies
// In production, this would use Zustand or a proper auth library
interface AuthUser {
id: string;
email?: string;
name?: string;
did?: string;
roles?: string[];
accessToken?: string;
refreshToken?: string;
}
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (user: AuthUser) => void;
logout: () => void;
setUser: (user: AuthUser | null) => void;
}
// Simple in-memory store with localStorage persistence
class AuthStore {
private state: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
login: () => {},
logout: () => {},
setUser: () => {},
};
private listeners: Set<(state: AuthState) => void> = new Set();
constructor() {
this.loadFromStorage();
}
private loadFromStorage() {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem('auth-storage');
if (stored) {
const parsed = JSON.parse(stored);
this.state.user = parsed.user || null;
this.state.isAuthenticated = !!this.state.user;
}
} catch {
// Ignore parse errors
}
}
private saveToStorage() {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('auth-storage', JSON.stringify({ user: this.state.user }));
} catch {
// Ignore storage errors
}
}
private notify() {
this.listeners.forEach((listener) => listener(this.state));
}
subscribe(listener: (state: AuthState) => void) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getState() {
return this.state;
}
setState(updates: Partial<AuthState>) {
this.state = { ...this.state, ...updates };
this.saveToStorage();
this.notify();
}
}
const authStore = typeof window !== 'undefined' ? new AuthStore() : null;
// React hook to use auth state
export function useAuth(): AuthState {
const [state, setState] = React.useState<AuthState>(
authStore?.getState() || {
user: null,
isAuthenticated: false,
isLoading: false,
login: () => {},
logout: () => {},
setUser: () => {},
}
);
React.useEffect(() => {
if (!authStore) return;
const unsubscribe = authStore.subscribe(setState);
return unsubscribe;
}, []);
return {
...state,
login: (user: AuthUser) => {
authStore?.setState({ user, isAuthenticated: true });
if (user.accessToken) {
localStorage.setItem('auth_token', user.accessToken);
}
},
logout: () => {
authStore?.setState({ user: null, isAuthenticated: false });
localStorage.removeItem('auth_token');
},
setUser: (user: AuthUser | null) => {
authStore?.setState({ user, isAuthenticated: !!user });
},
};
}

View File

@@ -0,0 +1,27 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
import { ToastProvider } from '@the-order/ui';
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Check if the request is for a protected route
const protectedRoutes = ['/review', '/credentials', '/metrics', '/audit'];
const isProtectedRoute = protectedRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
if (isProtectedRoute) {
// Check for auth token in cookies or headers
const token = request.cookies.get('auth_token') || request.headers.get('authorization');
if (!token) {
// Redirect to login if not authenticated
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/review/:path*', '/credentials/:path*', '/metrics/:path*', '/audit/:path*'],
};