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:
@@ -1,7 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@the-order/ui', '@the-order/schemas', '@the-order/auth'],
|
||||
transpilePackages: ['@the-order/ui', '@the-order/schemas', '@the-order/auth', '@the-order/api-client'],
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -15,7 +15,20 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"@the-order/ui": "workspace:*",
|
||||
"@the-order/schemas": "workspace:*",
|
||||
"@the-order/auth": "workspace:*"
|
||||
"@the-order/auth": "workspace:*",
|
||||
"@the-order/api-client": "workspace:*",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"zustand": "^4.4.7",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"lucide-react": "^0.309.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"zod": "^3.22.4",
|
||||
"date-fns": "^3.0.6",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
@@ -23,7 +36,11 @@
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"typescript": "^5.3.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.0.4"
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8.4.33",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
apps/portal-internal/postcss.config.js
Normal file
7
apps/portal-internal/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
196
apps/portal-internal/src/app/audit/page.tsx
Normal file
196
apps/portal-internal/src/app/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
244
apps/portal-internal/src/app/credentials/issue/page.tsx
Normal file
244
apps/portal-internal/src/app/credentials/issue/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
117
apps/portal-internal/src/app/credentials/page.tsx
Normal file
117
apps/portal-internal/src/app/credentials/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
60
apps/portal-internal/src/app/globals.css
Normal file
60
apps/portal-internal/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
126
apps/portal-internal/src/app/login/page.tsx
Normal file
126
apps/portal-internal/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
189
apps/portal-internal/src/app/metrics/page.tsx
Normal file
189
apps/portal-internal/src/app/metrics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
271
apps/portal-internal/src/app/review/[id]/page.tsx
Normal file
271
apps/portal-internal/src/app/review/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
120
apps/portal-internal/src/app/review/page.tsx
Normal file
120
apps/portal-internal/src/app/review/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
134
apps/portal-internal/src/app/settings/page.tsx
Normal file
134
apps/portal-internal/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
119
apps/portal-internal/src/app/users/page.tsx
Normal file
119
apps/portal-internal/src/app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
31
apps/portal-internal/src/components/AuthGuard.tsx
Normal file
31
apps/portal-internal/src/components/AuthGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
|
||||
54
apps/portal-internal/src/components/Header.tsx
Normal file
54
apps/portal-internal/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/portal-internal/src/lib/auth.ts
Normal file
126
apps/portal-internal/src/lib/auth.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
27
apps/portal-internal/src/lib/providers.tsx
Normal file
27
apps/portal-internal/src/lib/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
27
apps/portal-internal/src/middleware.ts
Normal file
27
apps/portal-internal/src/middleware.ts
Normal 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*'],
|
||||
};
|
||||
|
||||
77
apps/portal-internal/tailwind.config.js
Normal file
77
apps/portal-internal/tailwind.config.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@the-order/ui', '@the-order/schemas'],
|
||||
transpilePackages: ['@the-order/ui', '@the-order/schemas', '@the-order/api-client'],
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -14,7 +14,20 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@the-order/ui": "workspace:*",
|
||||
"@the-order/schemas": "workspace:*"
|
||||
"@the-order/schemas": "workspace:*",
|
||||
"@the-order/api-client": "workspace:*",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"zustand": "^4.4.7",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"lucide-react": "^0.309.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"zod": "^3.22.4",
|
||||
"date-fns": "^3.0.6",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
@@ -22,7 +35,11 @@
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"typescript": "^5.3.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.0.4"
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8.4.33",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
apps/portal-public/postcss.config.js
Normal file
7
apps/portal-public/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
116
apps/portal-public/src/app/about/page.tsx
Normal file
116
apps/portal-public/src/app/about/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
|
||||
|
||||
export default function AboutPage() {
|
||||
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-4xl font-bold text-gray-900 mb-4">About The Order</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
The Order of Military Hospitallers - A constitutional sovereign structure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Our Mission</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
The Order of Military Hospitallers is a constitutional sovereign structure dedicated to
|
||||
providing digital identity, legal services, and governance frameworks for the modern era.
|
||||
</p>
|
||||
<p>
|
||||
We operate as a decentralized sovereign body (DSB), offering eResidency and eCitizenship
|
||||
programs that provide individuals with verifiable digital credentials and access to our
|
||||
services.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>What We Offer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>
|
||||
<strong>eResidency:</strong> Digital residency credentials for individuals seeking
|
||||
to participate in The Order's ecosystem
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>
|
||||
<strong>eCitizenship:</strong> Full citizenship credentials with governance rights
|
||||
and responsibilities
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>
|
||||
<strong>Verifiable Credentials:</strong> Secure, tamper-proof digital credentials
|
||||
based on W3C standards
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>
|
||||
<strong>Legal Services:</strong> Access to the International Criminal Court of
|
||||
Commerce (ICCC)
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>
|
||||
<strong>Financial Services:</strong> Digital Bank of International Settlements
|
||||
(DBIS) infrastructure
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Governance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
The Order operates under a constitutional framework with:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4">
|
||||
<li>Founding Council for strategic decisions</li>
|
||||
<li>Judicial arm (ICCC) for legal matters</li>
|
||||
<li>Administrative structures for day-to-day operations</li>
|
||||
<li>Transparent governance and audit processes</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Technology</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
Our platform is built on modern, secure technologies:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4">
|
||||
<li>Decentralized Identifiers (DIDs) for identity management</li>
|
||||
<li>Verifiable Credentials (VCs) following W3C standards</li>
|
||||
<li>eIDAS-compliant qualified signatures</li>
|
||||
<li>Blockchain and cryptographic security</li>
|
||||
<li>Open-source and transparent systems</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
235
apps/portal-public/src/app/apply/page.tsx
Normal file
235
apps/portal-public/src/app/apply/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'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 } from '@the-order/ui';
|
||||
import { getApiClient } from '@the-order/api-client';
|
||||
import { useToast } from '@the-order/ui';
|
||||
|
||||
export default function ApplyPage() {
|
||||
const router = useRouter();
|
||||
const apiClient = getApiClient();
|
||||
const { success, error: showError } = useToast();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
givenName: '',
|
||||
familyName: '',
|
||||
dateOfBirth: '',
|
||||
nationality: '',
|
||||
phone: '',
|
||||
street: '',
|
||||
city: '',
|
||||
region: '',
|
||||
postalCode: '',
|
||||
country: '',
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
return apiClient.eresidency.submitApplication({
|
||||
email: data.email,
|
||||
givenName: data.givenName,
|
||||
familyName: data.familyName,
|
||||
dateOfBirth: data.dateOfBirth || undefined,
|
||||
nationality: data.nationality || undefined,
|
||||
phone: data.phone || undefined,
|
||||
address: data.street || data.city || data.region || data.postalCode || data.country
|
||||
? {
|
||||
street: data.street || undefined,
|
||||
city: data.city || undefined,
|
||||
region: data.region || undefined,
|
||||
postalCode: data.postalCode || undefined,
|
||||
country: data.country || undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
success('Application submitted successfully!', 'Your eResidency application has been received.');
|
||||
router.push(`/status?id=${data.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
showError(
|
||||
error instanceof Error ? error.message : 'Failed to submit application',
|
||||
'Submission Error'
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Apply for eResidency</CardTitle>
|
||||
<CardDescription>
|
||||
Complete the form below to apply for eResidency with The Order
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="givenName">Given Name *</Label>
|
||||
<Input
|
||||
id="givenName"
|
||||
name="givenName"
|
||||
required
|
||||
value={formData.givenName}
|
||||
onChange={handleChange}
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="familyName">Family Name *</Label>
|
||||
<Input
|
||||
id="familyName"
|
||||
name="familyName"
|
||||
required
|
||||
value={formData.familyName}
|
||||
onChange={handleChange}
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="dateOfBirth">Date of Birth</Label>
|
||||
<Input
|
||||
id="dateOfBirth"
|
||||
name="dateOfBirth"
|
||||
type="date"
|
||||
value={formData.dateOfBirth}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="nationality">Nationality</Label>
|
||||
<Input
|
||||
id="nationality"
|
||||
name="nationality"
|
||||
value={formData.nationality}
|
||||
onChange={handleChange}
|
||||
placeholder="US"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Address (Optional)</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="street">Street Address</Label>
|
||||
<Input
|
||||
id="street"
|
||||
name="street"
|
||||
value={formData.street}
|
||||
onChange={handleChange}
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="city">City</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
placeholder="New York"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="region">Region/State</Label>
|
||||
<Input
|
||||
id="region"
|
||||
name="region"
|
||||
value={formData.region}
|
||||
onChange={handleChange}
|
||||
placeholder="NY"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="postalCode">Postal Code</Label>
|
||||
<Input
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
value={formData.postalCode}
|
||||
onChange={handleChange}
|
||||
placeholder="10001"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="country">Country</Label>
|
||||
<Input
|
||||
id="country"
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleChange}
|
||||
placeholder="United States"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="secondary" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Submitting...' : 'Submit Application'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
121
apps/portal-public/src/app/contact/page.tsx
Normal file
121
apps/portal-public/src/app/contact/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Textarea, Button, useToast } from '@the-order/ui';
|
||||
|
||||
export default function ContactPage() {
|
||||
const { success, error: showError } = useToast();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
success('Message sent successfully!', 'We will get back to you soon.');
|
||||
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||
} catch (err) {
|
||||
showError('Failed to send message', 'Please try again later.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4 max-w-2xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Contact Us</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Have questions? We'd love to hear from you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send us a message</CardTitle>
|
||||
<CardDescription>Fill out the form below and we'll get back to you as soon as possible.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
required
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
required
|
||||
rows={6}
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Other ways to reach us</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Email</h3>
|
||||
<p>support@theorder.org</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Response Time</h3>
|
||||
<p>We typically respond within 24-48 hours.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
128
apps/portal-public/src/app/docs/page.tsx
Normal file
128
apps/portal-public/src/app/docs/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function DocsPage() {
|
||||
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-4xl font-bold text-gray-900 mb-4">Documentation</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Learn how to use The Order's services and APIs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Getting Started</CardTitle>
|
||||
<CardDescription>Quick start guide for new users</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">1. Apply for eResidency</h3>
|
||||
<p className="text-gray-700">
|
||||
Start by submitting an eResidency application through our{' '}
|
||||
<Link href="/apply" className="text-primary hover:underline">
|
||||
application form
|
||||
</Link>
|
||||
. You'll need to provide personal information and identity documents.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">2. Wait for Review</h3>
|
||||
<p className="text-gray-700">
|
||||
Our team will review your application. You can check the status using your
|
||||
application ID on the{' '}
|
||||
<Link href="/status" className="text-primary hover:underline">
|
||||
status page
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">3. Receive Credentials</h3>
|
||||
<p className="text-gray-700">
|
||||
Once approved, you'll receive verifiable credentials that you can use to access
|
||||
services and prove your identity.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Documentation</CardTitle>
|
||||
<CardDescription>RESTful API endpoints and integration guides</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">Identity Service</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
Manage verifiable credentials, issue new credentials, and verify existing ones.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
|
||||
<li>POST /api/v1/credentials/issue - Issue a new credential</li>
|
||||
<li>POST /api/v1/credentials/verify - Verify a credential</li>
|
||||
<li>GET /api/v1/credentials/{'{id}'} - Get credential details</li>
|
||||
<li>POST /api/v1/credentials/{'{id}'}/revoke - Revoke a credential</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">eResidency Service</h3>
|
||||
<p className="text-gray-700 mb-2">
|
||||
Submit and manage eResidency applications.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-600 ml-4">
|
||||
<li>POST /api/v1/applications - Submit an application</li>
|
||||
<li>GET /api/v1/applications/{'{id}'} - Get application status</li>
|
||||
<li>GET /api/v1/applications - List applications (admin)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verifiable Credentials</CardTitle>
|
||||
<CardDescription>Understanding digital credentials</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
Verifiable Credentials are tamper-proof digital documents that prove your identity
|
||||
or qualifications. They follow W3C standards and can be verified by anyone with
|
||||
access to the public key.
|
||||
</p>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Features:</h3>
|
||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Cryptographically signed and tamper-proof</li>
|
||||
<li>Privacy-preserving - you control what information to share</li>
|
||||
<li>Interoperable - works with standard verifiers</li>
|
||||
<li>Revocable - can be revoked if compromised</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Support</CardTitle>
|
||||
<CardDescription>Need help? We're here for you</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
If you have questions or need assistance, please{' '}
|
||||
<Link href="/contact" className="text-primary hover:underline">
|
||||
contact our support team
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
43
apps/portal-public/src/app/error.tsx
Normal file
43
apps/portal-public/src/app/error.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full text-center">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-6xl font-bold text-red-600 mb-4">500</CardTitle>
|
||||
<CardDescription className="text-xl">Something went wrong</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
An unexpected error occurred. Please try again or contact support if the problem
|
||||
persists.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-sm text-gray-500 font-mono">Error ID: {error.digest}</p>
|
||||
)}
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button onClick={reset}>Try Again</Button>
|
||||
<Button variant="outline" onClick={() => (window.location.href = '/')}>
|
||||
Go Home
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
60
apps/portal-public/src/app/globals.css
Normal file
60
apps/portal-public/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { ReactNode } from 'react';
|
||||
import './globals.css';
|
||||
import { Providers } from '../lib/providers';
|
||||
import { Header } from '../components/Header';
|
||||
import { Footer } from '../components/Footer';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'The Order - Public Portal',
|
||||
@@ -13,7 +17,13 @@ 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>
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
131
apps/portal-public/src/app/login/page.tsx
Normal file
131
apps/portal-public/src/app/login/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'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
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
email: formData.email,
|
||||
name: formData.email.split('@')[0],
|
||||
accessToken: 'mock-access-token-' + Date.now(),
|
||||
roles: ['user'],
|
||||
};
|
||||
|
||||
login(mockUser);
|
||||
success('Login successful', 'Welcome back!');
|
||||
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>Login</CardTitle>
|
||||
<CardDescription>Sign in to your account</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="your.email@example.com"
|
||||
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>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<a href="/register" className="text-primary hover:underline">
|
||||
Sign up
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
29
apps/portal-public/src/app/not-found.tsx
Normal file
29
apps/portal-public/src/app/not-found.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link';
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<Card className="max-w-md w-full text-center">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-6xl font-bold text-gray-900 mb-4">404</CardTitle>
|
||||
<CardDescription className="text-xl">Page Not Found</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link href="/">
|
||||
<Button>Go Home</Button>
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,94 @@
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<h1>The Order - Public Portal</h1>
|
||||
<p>Welcome to The Order public portal.</p>
|
||||
</main>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">The Order</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-6">
|
||||
Order of Military Hospitallers - Digital Identity & Governance Platform
|
||||
</p>
|
||||
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
|
||||
Apply for eResidency, verify credentials, and access our services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Apply for eResidency</CardTitle>
|
||||
<CardDescription>Start your application for eResidency with The Order</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/apply" className="text-primary hover:underline font-medium">
|
||||
Begin Application →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Check Application Status</CardTitle>
|
||||
<CardDescription>View the status of your eResidency application</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/status" className="text-primary hover:underline font-medium">
|
||||
Check Status →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Credential</CardTitle>
|
||||
<CardDescription>Verify a verifiable credential issued by The Order</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/verify" className="text-primary hover:underline font-medium">
|
||||
Verify Credential →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About The Order</CardTitle>
|
||||
<CardDescription>Learn about The Order and our mission</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/about" className="text-primary hover:underline font-medium">
|
||||
Learn More →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Documentation</CardTitle>
|
||||
<CardDescription>Access documentation and help resources</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/docs" className="text-primary hover:underline font-medium">
|
||||
View Docs →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contact</CardTitle>
|
||||
<CardDescription>Get in touch with our support team</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/contact" className="text-primary hover:underline font-medium">
|
||||
Contact Us →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
94
apps/portal-public/src/app/privacy/page.tsx
Normal file
94
apps/portal-public/src/app/privacy/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
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-4xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
||||
<p className="text-lg text-gray-600">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Information We Collect</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
We collect information that you provide directly to us, including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4">
|
||||
<li>Personal identification information (name, email, date of birth)</li>
|
||||
<li>Identity document information (for verification purposes)</li>
|
||||
<li>Application and credential data</li>
|
||||
<li>Usage data and analytics</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>2. How We Use Your Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>We use the information we collect to:</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4">
|
||||
<li>Process and review your eResidency applications</li>
|
||||
<li>Issue and manage verifiable credentials</li>
|
||||
<li>Provide and improve our services</li>
|
||||
<li>Comply with legal obligations</li>
|
||||
<li>Prevent fraud and ensure security</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>3. Data Security</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
We implement appropriate technical and organizational measures to protect your
|
||||
personal information against unauthorized access, alteration, disclosure, or
|
||||
destruction.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>4. Your Rights</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>You have the right to:</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4">
|
||||
<li>Access your personal information</li>
|
||||
<li>Request correction of inaccurate data</li>
|
||||
<li>Request deletion of your data</li>
|
||||
<li>Object to processing of your data</li>
|
||||
<li>Request data portability</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>5. Contact Us</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-gray-700">
|
||||
<p>
|
||||
If you have questions about this Privacy Policy, please contact us at{' '}
|
||||
<a href="mailto:privacy@theorder.org" className="text-primary hover:underline">
|
||||
privacy@theorder.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
168
apps/portal-public/src/app/status/page.tsx
Normal file
168
apps/portal-public/src/app/status/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Label } from '@the-order/ui';
|
||||
import { getApiClient } from '@the-order/api-client';
|
||||
|
||||
export default function StatusPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const applicationId = searchParams.get('id');
|
||||
const apiClient = getApiClient();
|
||||
|
||||
const { data: application, isLoading, error } = useQuery({
|
||||
queryKey: ['application', applicationId],
|
||||
queryFn: () => apiClient.eresidency.getApplication(applicationId!),
|
||||
enabled: !!applicationId,
|
||||
});
|
||||
|
||||
if (!applicationId) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Application Status</CardTitle>
|
||||
<CardDescription>Please provide an application ID to check status</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4 max-w-2xl">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-gray-600">Loading application status...</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 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Error</CardTitle>
|
||||
<CardDescription>Failed to load application status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-red-600">{error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!application) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Application Not Found</CardTitle>
|
||||
<CardDescription>No application found with the provided ID</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'rejected':
|
||||
return 'text-red-600 bg-red-50';
|
||||
case 'under_review':
|
||||
return 'text-blue-600 bg-blue-50';
|
||||
case 'kyc_pending':
|
||||
return 'text-yellow-600 bg-yellow-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 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Application Status</CardTitle>
|
||||
<CardDescription>Application ID: {application.id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-500">Status</Label>
|
||||
<div className={`mt-1 px-3 py-2 rounded-md inline-block ${getStatusColor(application.status)}`}>
|
||||
{application.status.toUpperCase().replace('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-500">Applicant</Label>
|
||||
<p className="mt-1 text-gray-900">
|
||||
{application.givenName} {application.familyName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{application.email}</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>
|
||||
)}
|
||||
|
||||
{application.reviewedAt && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-500">Reviewed At</Label>
|
||||
<p className="mt-1 text-gray-900">{new Date(application.reviewedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{application.reviewedBy && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-500">Reviewed By</Label>
|
||||
<p className="mt-1 text-gray-900">{application.reviewedBy}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{application.rejectionReason && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-500">Rejection Reason</Label>
|
||||
<p className="mt-1 text-red-600">{application.rejectionReason}</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
102
apps/portal-public/src/app/terms/page.tsx
Normal file
102
apps/portal-public/src/app/terms/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@the-order/ui';
|
||||
|
||||
export default function TermsPage() {
|
||||
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-4xl font-bold text-gray-900 mb-4">Terms of Service</h1>
|
||||
<p className="text-lg text-gray-600">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Acceptance of Terms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-gray-700">
|
||||
<p>
|
||||
By accessing and using The Order's services, you accept and agree to be bound by
|
||||
these Terms of Service. If you do not agree, you may not use our services.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>2. Description of Services</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>
|
||||
The Order provides digital identity and credential management services, including:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4">
|
||||
<li>eResidency and eCitizenship applications</li>
|
||||
<li>Verifiable credential issuance and management</li>
|
||||
<li>Identity verification services</li>
|
||||
<li>Access to governance and legal services</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>3. User Responsibilities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-700">
|
||||
<p>You agree to:</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-4">
|
||||
<li>Provide accurate and truthful information</li>
|
||||
<li>Maintain the security of your credentials</li>
|
||||
<li>Use services only for lawful purposes</li>
|
||||
<li>Not attempt to circumvent security measures</li>
|
||||
<li>Comply with all applicable laws and regulations</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>4. Intellectual Property</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-gray-700">
|
||||
<p>
|
||||
All content, features, and functionality of our services are owned by The Order and
|
||||
are protected by international copyright, trademark, and other intellectual property
|
||||
laws.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>5. Limitation of Liability</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-gray-700">
|
||||
<p>
|
||||
The Order shall not be liable for any indirect, incidental, special, consequential,
|
||||
or punitive damages resulting from your use of our services.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>6. Contact Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-gray-700">
|
||||
<p>
|
||||
For questions about these Terms, contact us at{' '}
|
||||
<a href="mailto:legal@theorder.org" className="text-primary hover:underline">
|
||||
legal@theorder.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
129
apps/portal-public/src/app/verify/page.tsx
Normal file
129
apps/portal-public/src/app/verify/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Button, Alert, AlertDescription, useToast } from '@the-order/ui';
|
||||
import { getApiClient } from '@the-order/api-client';
|
||||
|
||||
export default function VerifyPage() {
|
||||
const apiClient = getApiClient();
|
||||
const { success, error: showError } = useToast();
|
||||
const [credentialId, setCredentialId] = useState('');
|
||||
const [verificationResult, setVerificationResult] = useState<{ valid: boolean; error?: string } | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return apiClient.identity.verifyCredential({
|
||||
credential: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setVerificationResult({ valid: data.valid });
|
||||
if (data.valid) {
|
||||
success('Credential verified successfully', 'This credential is valid and authentic.');
|
||||
} else {
|
||||
showError('Credential verification failed', 'This credential could not be verified.');
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
|
||||
setVerificationResult({
|
||||
valid: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
showError(errorMessage, 'Verification Error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!credentialId.trim()) {
|
||||
setVerificationResult({ valid: false, error: 'Please enter a credential ID' });
|
||||
return;
|
||||
}
|
||||
mutation.mutate(credentialId.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Credential</CardTitle>
|
||||
<CardDescription>
|
||||
Enter a credential ID to verify its validity and authenticity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="credentialId">Credential ID</Label>
|
||||
<Input
|
||||
id="credentialId"
|
||||
type="text"
|
||||
placeholder="Enter credential ID"
|
||||
value={credentialId}
|
||||
onChange={(e) => setCredentialId(e.target.value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
The credential ID is typically a UUID or DID identifier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{verificationResult && (
|
||||
<Alert variant={verificationResult.valid ? 'success' : 'destructive'}>
|
||||
<AlertDescription>
|
||||
{verificationResult.valid ? (
|
||||
<div>
|
||||
<p className="font-semibold">✓ Credential Verified</p>
|
||||
<p className="text-sm mt-1">This credential is valid and authentic.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="font-semibold">✗ Verification Failed</p>
|
||||
<p className="text-sm mt-1">
|
||||
{verificationResult.error || 'The credential could not be verified.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Verifying...' : 'Verify Credential'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>About Credential Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-600">
|
||||
<p>
|
||||
Credential verification checks the authenticity and validity of verifiable credentials
|
||||
issued by The Order.
|
||||
</p>
|
||||
<p>
|
||||
The verification process validates:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Credential signature and proof</li>
|
||||
<li>Credential expiration status</li>
|
||||
<li>Revocation status</li>
|
||||
<li>Issuer authenticity</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
31
apps/portal-public/src/components/AuthGuard.tsx
Normal file
31
apps/portal-public/src/components/AuthGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
|
||||
77
apps/portal-public/src/components/Footer.tsx
Normal file
77
apps/portal-public/src/components/Footer.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t bg-gray-50 mt-auto">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-4">The Order</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Digital identity and credential management for the Order of Military Hospitallers.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-4">Services</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/apply" className="text-gray-600 hover:text-gray-900">
|
||||
Apply for eResidency
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/verify" className="text-gray-600 hover:text-gray-900">
|
||||
Verify Credentials
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/status" className="text-gray-600 hover:text-gray-900">
|
||||
Check Status
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-4">Resources</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/about" className="text-gray-600 hover:text-gray-900">
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs" className="text-gray-600 hover:text-gray-900">
|
||||
Documentation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact" className="text-gray-600 hover:text-gray-900">
|
||||
Contact
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-4">Legal</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/privacy" className="text-gray-600 hover:text-gray-900">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/terms" className="text-gray-600 hover:text-gray-900">
|
||||
Terms of Service
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t text-center text-sm text-gray-600">
|
||||
<p>© {new Date().getFullYear()} The Order. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
54
apps/portal-public/src/components/Header.tsx
Normal file
54
apps/portal-public/src/components/Header.tsx
Normal 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('/');
|
||||
};
|
||||
|
||||
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
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link href="/apply" className="text-gray-600 hover:text-gray-900">
|
||||
Apply
|
||||
</Link>
|
||||
<Link href="/status" className="text-gray-600 hover:text-gray-900">
|
||||
Status
|
||||
</Link>
|
||||
<Link href="/verify" className="text-gray-600 hover:text-gray-900">
|
||||
Verify
|
||||
</Link>
|
||||
<Link href="/about" className="text-gray-600 hover:text-gray-900">
|
||||
About
|
||||
</Link>
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{user?.email || user?.name}</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>
|
||||
);
|
||||
}
|
||||
126
apps/portal-public/src/lib/auth.ts
Normal file
126
apps/portal-public/src/lib/auth.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
27
apps/portal-public/src/lib/providers.tsx
Normal file
27
apps/portal-public/src/lib/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
27
apps/portal-public/src/middleware.ts
Normal file
27
apps/portal-public/src/middleware.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Public portal routes are generally open
|
||||
// Only protect specific routes if needed
|
||||
const protectedRoutes: string[] = []; // Add protected routes here if needed
|
||||
|
||||
const isProtectedRoute = protectedRoutes.some((route) => request.nextUrl.pathname.startsWith(route));
|
||||
|
||||
if (isProtectedRoute) {
|
||||
const token = request.cookies.get('auth_token') || request.headers.get('authorization');
|
||||
|
||||
if (!token) {
|
||||
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: [], // Add protected routes here if needed
|
||||
};
|
||||
|
||||
77
apps/portal-public/tailwind.config.js
Normal file
77
apps/portal-public/tailwind.config.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user