mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-04 18:31:41 +00:00
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
User,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table';
|
||||
|
||||
/**
|
||||
* Audit log entry structure
|
||||
*/
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
actor: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'user' | 'service' | 'system';
|
||||
};
|
||||
action: 'grant' | 'revoke' | 'request' | 'deny' | 'expire' | 'attest';
|
||||
resource: string;
|
||||
permission: string;
|
||||
status: 'success' | 'failed' | 'pending';
|
||||
reason?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionAuditLog component
|
||||
*/
|
||||
export interface PermissionAuditLogProps {
|
||||
entries: AuditLogEntry[];
|
||||
loading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onExport?: () => void;
|
||||
showFilters?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table display for permission audit trail
|
||||
*/
|
||||
export function PermissionAuditLog({
|
||||
entries,
|
||||
loading = false,
|
||||
onRefresh,
|
||||
onExport,
|
||||
showFilters = true,
|
||||
className,
|
||||
}: PermissionAuditLogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterAction, setFilterAction] = useState<string>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
|
||||
const filteredEntries = entries.filter((entry) => {
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
entry.actor.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
entry.resource.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
entry.permission.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesAction = filterAction === 'all' || entry.action === filterAction;
|
||||
const matchesStatus = filterStatus === 'all' || entry.status === filterStatus;
|
||||
|
||||
return matchesSearch && matchesAction && matchesStatus;
|
||||
});
|
||||
|
||||
const getActionIcon = (action: AuditLogEntry['action']) => {
|
||||
switch (action) {
|
||||
case 'grant':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'revoke':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'request':
|
||||
return <Clock className="h-4 w-4 text-blue-500" />;
|
||||
case 'deny':
|
||||
return <XCircle className="h-4 w-4 text-orange-500" />;
|
||||
case 'expire':
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||
case 'attest':
|
||||
return <Shield className="h-4 w-4 text-purple-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: AuditLogEntry['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Success
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Failed
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getActorIcon = (type: AuditLogEntry['actor']['type']) => {
|
||||
switch (type) {
|
||||
case 'user':
|
||||
return <User className="h-3 w-3" />;
|
||||
case 'service':
|
||||
return <Shield className="h-3 w-3" />;
|
||||
case 'system':
|
||||
return <Clock className="h-3 w-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Permission Audit Log</CardTitle>
|
||||
<CardDescription>Track all permission changes and access requests</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{onRefresh && (
|
||||
<Button size="sm" variant="outline" onClick={onRefresh} disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
)}
|
||||
{onExport && (
|
||||
<Button size="sm" variant="outline" onClick={onExport}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showFilters && (
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by actor, resource, or permission..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterAction} onValueChange={setFilterAction}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Actions</SelectItem>
|
||||
<SelectItem value="grant">Grant</SelectItem>
|
||||
<SelectItem value="revoke">Revoke</SelectItem>
|
||||
<SelectItem value="request">Request</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
<SelectItem value="expire">Expire</SelectItem>
|
||||
<SelectItem value="attest">Attest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">Timestamp</TableHead>
|
||||
<TableHead>Actor</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Resource</TableHead>
|
||||
<TableHead>Permission</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Reason</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Loading audit logs...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
No audit log entries found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredEntries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{entry.timestamp.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getActorIcon(entry.actor.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{entry.actor.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{entry.actor.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getActionIcon(entry.action)}
|
||||
<span className="text-sm capitalize">{entry.action}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{entry.resource}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{entry.permission}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(entry.status)}</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground">
|
||||
{entry.reason || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { Label } from '../../ui/label';
|
||||
|
||||
/**
|
||||
* Permission item structure
|
||||
*/
|
||||
export interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
resource?: string;
|
||||
action?: string;
|
||||
enabled: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionGrid component
|
||||
*/
|
||||
export interface PermissionGridProps {
|
||||
permissions: Permission[];
|
||||
onChange?: (permissions: Permission[]) => void;
|
||||
readOnly?: boolean;
|
||||
groupByCategory?: boolean;
|
||||
showDescriptions?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid display for managing multiple permissions
|
||||
*/
|
||||
export function PermissionGrid({
|
||||
permissions,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
groupByCategory = true,
|
||||
showDescriptions = true,
|
||||
className,
|
||||
}: PermissionGridProps) {
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Permission[]>(permissions);
|
||||
|
||||
const handlePermissionChange = (permission: Permission, checked: boolean) => {
|
||||
const updated = selectedPermissions.map((p) =>
|
||||
p.id === permission.id ? { ...p, enabled: checked } : p
|
||||
);
|
||||
setSelectedPermissions(updated);
|
||||
onChange?.(updated);
|
||||
};
|
||||
|
||||
const groupedPermissions = groupByCategory
|
||||
? selectedPermissions.reduce<Record<string, Permission[]>>((acc, permission) => {
|
||||
const category = permission.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category]?.push(permission);
|
||||
return acc;
|
||||
}, {})
|
||||
: { 'All Permissions': selectedPermissions };
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{Object.entries(groupedPermissions).map(([category, perms]) => (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{category}</CardTitle>
|
||||
<CardDescription>
|
||||
{perms.filter((p) => p.enabled).length} of {perms.length} permissions enabled
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{perms.map((permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 rounded-lg border p-3',
|
||||
permission.enabled && 'bg-accent/50',
|
||||
permission.required && 'border-primary'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={permission.id}
|
||||
checked={permission.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePermissionChange(permission, checked as boolean)
|
||||
}
|
||||
disabled={readOnly || permission.required}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label
|
||||
htmlFor={permission.id}
|
||||
className={cn(
|
||||
'text-sm font-medium cursor-pointer',
|
||||
readOnly && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
>
|
||||
{permission.name}
|
||||
{permission.required && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground">{permission.description}</p>
|
||||
)}
|
||||
{(permission.resource || permission.action) && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
{permission.resource && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.resource}
|
||||
</Badge>
|
||||
)}
|
||||
{permission.action && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, Clock, FileText, Info, Key, Shield, User } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Alert, AlertDescription } from '../../ui/alert';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../../ui/dialog';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
// import { cn } from "../../../lib/utils" // Uncomment if needed
|
||||
|
||||
/**
|
||||
* Permission request data structure
|
||||
*/
|
||||
export interface PermissionRequestData {
|
||||
requester: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'user' | 'service';
|
||||
};
|
||||
permissions: Array<{
|
||||
resource: string;
|
||||
action: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
duration?: {
|
||||
value: number;
|
||||
unit: 'hours' | 'days' | 'weeks' | 'months';
|
||||
};
|
||||
justification: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionRequest component
|
||||
*/
|
||||
export interface PermissionRequestProps {
|
||||
onSubmit?: (request: PermissionRequestData) => Promise<void>;
|
||||
availablePermissions?: Array<{
|
||||
resource: string;
|
||||
actions: string[];
|
||||
}>;
|
||||
requester?: PermissionRequestData['requester'];
|
||||
maxDuration?: number;
|
||||
showJustification?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog workflow for requesting permissions
|
||||
*/
|
||||
export function PermissionRequest({
|
||||
onSubmit,
|
||||
availablePermissions = [],
|
||||
requester,
|
||||
maxDuration = 30,
|
||||
showJustification = true,
|
||||
className,
|
||||
}: PermissionRequestProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [formData, setFormData] = useState<Partial<PermissionRequestData>>({
|
||||
requester: requester || { id: '', name: '', type: 'user' },
|
||||
permissions: [],
|
||||
justification: '',
|
||||
});
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Set<string>>(new Set());
|
||||
|
||||
const handlePermissionToggle = (resource: string, action: string) => {
|
||||
const key = `${resource}:${action}`;
|
||||
const newSelected = new Set(selectedPermissions);
|
||||
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
|
||||
setSelectedPermissions(newSelected);
|
||||
|
||||
// Update form data
|
||||
const permissions = Array.from(newSelected).map((k) => {
|
||||
const [res, act] = k.split(':');
|
||||
return { resource: res || '', action: act || '' };
|
||||
});
|
||||
setFormData((prev) => ({ ...prev, permissions }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.permissions || formData.permissions.length === 0) {
|
||||
setError('Please select at least one permission');
|
||||
return;
|
||||
}
|
||||
|
||||
if (showJustification && !formData.justification) {
|
||||
setError('Please provide a justification');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
await onSubmit?.(formData as PermissionRequestData);
|
||||
setOpen(false);
|
||||
// Reset form
|
||||
setFormData({
|
||||
requester: requester || { id: '', name: '', type: 'user' },
|
||||
permissions: [],
|
||||
justification: '',
|
||||
});
|
||||
setSelectedPermissions(new Set());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit request');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className={className}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Request Permissions
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Request Permissions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the permissions you need and provide justification for your request
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Requester Information */}
|
||||
{requester && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Requester</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{requester.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{requester.type === 'service' ? 'Service Account' : 'User Account'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Permission Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>Select Permissions</Label>
|
||||
{availablePermissions.length === 0 ? (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>No permissions available to request</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availablePermissions.map((perm) => (
|
||||
<Card key={perm.resource}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">{perm.resource}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{perm.actions.map((action) => {
|
||||
const key = `${perm.resource}:${action}`;
|
||||
return (
|
||||
<div key={action} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={key}
|
||||
checked={selectedPermissions.has(key)}
|
||||
onCheckedChange={() =>
|
||||
handlePermissionToggle(perm.resource, action)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={key} className="text-sm cursor-pointer">
|
||||
{action}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="duration">Duration (Optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxDuration}
|
||||
placeholder="Duration"
|
||||
value={formData.duration?.value || ''}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value);
|
||||
if (value > 0) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
duration: {
|
||||
value,
|
||||
unit: prev.duration?.unit || 'days',
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={formData.duration?.unit || 'days'}
|
||||
onChange={(e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
duration: {
|
||||
value: prev.duration?.value || 7,
|
||||
unit: e.target.value as any,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="hours">Hours</option>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty for permanent permissions (subject to approval)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Justification */}
|
||||
{showJustification && (
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="justification">
|
||||
Justification <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="justification"
|
||||
placeholder="Explain why you need these permissions..."
|
||||
value={formData.justification}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, justification: e.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a clear business justification for this request
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Selected Permissions Summary */}
|
||||
{selectedPermissions.size > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Request Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formData.duration
|
||||
? `${formData.duration.value} ${formData.duration.unit}`
|
||||
: 'Permanent (subject to approval)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from(selectedPermissions).map((key) => (
|
||||
<Badge key={key} variant="secondary" className="text-xs">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || selectedPermissions.size === 0}>
|
||||
{loading ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../ui/select';
|
||||
|
||||
/**
|
||||
* Permission template structure
|
||||
*/
|
||||
export interface PermissionTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
category: 'basic' | 'standard' | 'advanced' | 'custom';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionSelector component
|
||||
*/
|
||||
export interface PermissionSelectorProps {
|
||||
templates: PermissionTemplate[];
|
||||
value?: string;
|
||||
onChange?: (templateId: string, template: PermissionTemplate) => void;
|
||||
showDetails?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown selector for permission templates with descriptions
|
||||
*/
|
||||
export function PermissionSelector({
|
||||
templates,
|
||||
value,
|
||||
onChange,
|
||||
showDetails = true,
|
||||
disabled = false,
|
||||
className,
|
||||
}: PermissionSelectorProps) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<PermissionTemplate | undefined>(
|
||||
templates.find((t) => t.id === value)
|
||||
);
|
||||
|
||||
const handleChange = (templateId: string) => {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setSelectedTemplate(template);
|
||||
onChange?.(templateId, template);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: PermissionTemplate['category']) => {
|
||||
switch (category) {
|
||||
case 'basic':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400';
|
||||
case 'standard':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400';
|
||||
case 'advanced':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400';
|
||||
case 'custom':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const groupedTemplates = templates.reduce<Record<string, PermissionTemplate[]>>(
|
||||
(acc, template) => {
|
||||
const category = template.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category]?.push(template);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Select value={selectedTemplate?.id || ''} onValueChange={handleChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a permission template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedTemplates).map(([category, temps]) => (
|
||||
<SelectGroup key={category}>
|
||||
<SelectLabel className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
getCategoryColor(category as PermissionTemplate['category'])
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
</SelectLabel>
|
||||
{temps.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id} className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.icon}
|
||||
<div>
|
||||
<div className="font-medium">{template.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{template.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showDetails && selectedTemplate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
|
||||
<Badge variant="secondary" className={getCategoryColor(selectedTemplate.category)}>
|
||||
{selectedTemplate.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>{selectedTemplate.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4" />
|
||||
<span>
|
||||
This template includes {selectedTemplate.permissions.length} permissions:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="outline" className="text-xs">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Key,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
|
||||
|
||||
/**
|
||||
* UCAN capability structure
|
||||
*/
|
||||
export interface UCANCapability {
|
||||
with: string; // Resource identifier
|
||||
can: string; // Action/permission
|
||||
nb?: Record<string, any>; // Additional constraints
|
||||
}
|
||||
|
||||
/**
|
||||
* UCAN token structure
|
||||
*/
|
||||
export interface UCANToken {
|
||||
iss: string; // Issuer DID
|
||||
aud: string; // Audience DID
|
||||
exp?: number; // Expiration timestamp
|
||||
nbf?: number; // Not before timestamp
|
||||
att: UCANCapability[]; // Attenuations/capabilities
|
||||
prf?: string[]; // Proof chain
|
||||
fct?: Record<string, any>; // Facts
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for UCANViewer component
|
||||
*/
|
||||
export interface UCANViewerProps {
|
||||
token: UCANToken;
|
||||
showRaw?: boolean;
|
||||
showProofChain?: boolean;
|
||||
onCopyToken?: () => void;
|
||||
onVerify?: () => Promise<boolean>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewer for UCAN token visualization and capability display
|
||||
*/
|
||||
export function UCANViewer({
|
||||
token,
|
||||
showRaw = true,
|
||||
showProofChain = true,
|
||||
onCopyToken,
|
||||
onVerify,
|
||||
className,
|
||||
}: UCANViewerProps) {
|
||||
const [expandedCapabilities, setExpandedCapabilities] = useState<Set<number>>(new Set());
|
||||
const [verificationStatus, setVerificationStatus] = useState<
|
||||
'idle' | 'verifying' | 'valid' | 'invalid'
|
||||
>('idle');
|
||||
|
||||
const toggleCapability = (index: number) => {
|
||||
const newExpanded = new Set(expandedCapabilities);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedCapabilities(newExpanded);
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!onVerify) return;
|
||||
setVerificationStatus('verifying');
|
||||
try {
|
||||
const isValid = await onVerify();
|
||||
setVerificationStatus(isValid ? 'valid' : 'invalid');
|
||||
} catch {
|
||||
setVerificationStatus('invalid');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return 'Never';
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const isExpired = token.exp && token.exp * 1000 < Date.now();
|
||||
const isNotYetValid = token.nbf && token.nbf * 1000 > Date.now();
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
<CardTitle>UCAN Token</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpired && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Expired
|
||||
</Badge>
|
||||
)}
|
||||
{isNotYetValid && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Not Yet Valid
|
||||
</Badge>
|
||||
)}
|
||||
{verificationStatus === 'valid' && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
{verificationStatus === 'invalid' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Invalid
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
User-Controlled Authorization Network token with delegated capabilities
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="details" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="capabilities">Capabilities ({token.att.length})</TabsTrigger>
|
||||
{showRaw && <TabsTrigger value="raw">Raw Token</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Key className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Issuer</p>
|
||||
<code className="text-xs text-muted-foreground break-all">{token.iss}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Key className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Audience</p>
|
||||
<code className="text-xs text-muted-foreground break-all">{token.aud}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{token.exp && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Expires</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(token.exp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{token.nbf && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Not Before</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(token.nbf)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProofChain && token.prf && token.prf.length > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Proof Chain</p>
|
||||
<div className="space-y-1 mt-1">
|
||||
{token.prf.map((proof, index) => (
|
||||
<code
|
||||
key={index}
|
||||
className="block text-xs text-muted-foreground break-all"
|
||||
>
|
||||
{proof}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{onVerify && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleVerify}
|
||||
disabled={verificationStatus === 'verifying'}
|
||||
>
|
||||
{verificationStatus === 'verifying' ? 'Verifying...' : 'Verify Token'}
|
||||
</Button>
|
||||
)}
|
||||
{onCopyToken && (
|
||||
<Button size="sm" variant="outline" onClick={onCopyToken}>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy Token
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="capabilities" className="space-y-3">
|
||||
{token.att.map((capability, index) => (
|
||||
<Card key={index} className="border-muted">
|
||||
<CardHeader
|
||||
className="cursor-pointer py-3"
|
||||
onClick={() => toggleCapability(index)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedCapabilities.has(index) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<Badge variant="outline">{capability.can}</Badge>
|
||||
<code className="text-xs text-muted-foreground">{capability.with}</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{expandedCapabilities.has(index) && capability.nb && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Constraints:</p>
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-auto">
|
||||
{JSON.stringify(capability.nb, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
{token.att.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No capabilities defined
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{showRaw && (
|
||||
<TabsContent value="raw" className="space-y-3">
|
||||
<pre className="text-xs bg-muted p-4 rounded overflow-auto">
|
||||
{JSON.stringify(token, null, 2)}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './PermissionGrid';
|
||||
export * from './PermissionSelector';
|
||||
export * from './UCANViewer';
|
||||
export * from './PermissionAuditLog';
|
||||
export * from './PermissionRequest';
|
||||
Reference in New Issue
Block a user