Merge pull request #256 from johndoe6345789/codex/add-user-management-components

feat: add user management subcomponents
This commit is contained in:
2025-12-27 18:49:05 +00:00
committed by GitHub
3 changed files with 423 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
'use client'
import { useMemo, useState } from 'react'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
ScrollArea,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui'
import { Clock, ShieldWarning, UserSwitch } from '@phosphor-icons/react'
export type AuditSeverity = 'info' | 'warning' | 'critical'
export interface AuditEvent {
id: string
actor: string
action: string
target?: string
timestamp: string | number
severity: AuditSeverity
}
interface AuditTrailProps {
events: AuditEvent[]
showSearch?: boolean
maxRows?: number
}
const SEVERITY_META: Record<AuditSeverity, { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
info: { label: 'Info', variant: 'secondary' },
warning: { label: 'Warning', variant: 'default' },
critical: { label: 'Critical', variant: 'destructive' },
}
const formatTime = (value: string | number) => new Date(value).toLocaleString()
export function AuditTrail({ events, showSearch = true, maxRows }: AuditTrailProps) {
const [query, setQuery] = useState('')
const [severity, setSeverity] = useState<AuditSeverity | 'all'>('all')
const filtered = useMemo(() => {
const text = query.toLowerCase()
return events
.filter((event) => {
const matchesText =
!text || `${event.actor} ${event.action} ${event.target ?? ''}`.toLowerCase().includes(text)
const matchesSeverity = severity === 'all' || event.severity === severity
return matchesText && matchesSeverity
})
.slice(0, maxRows ?? events.length)
}, [events, query, severity, maxRows])
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Audit trail</CardTitle>
<CardDescription>Recent security-sensitive actions.</CardDescription>
</div>
<Badge variant="outline" className="gap-1">
<UserSwitch size={14} />
{events.length} events
</Badge>
</div>
{showSearch && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="audit-query">Search</Label>
<Input
id="audit-query"
placeholder="Filter by actor or action"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="audit-severity">Severity</Label>
<select
id="audit-severity"
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={severity}
onChange={(event) => setSeverity(event.target.value as AuditSeverity | 'all')}
>
<option value="all">All events</option>
{Object.entries(SEVERITY_META).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
</div>
</div>
)}
</CardHeader>
<CardContent>
<ScrollArea className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-36">Timestamp</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-32">Severity</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((event) => (
<TableRow key={event.id}>
<TableCell className="text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock size={14} />
{formatTime(event.timestamp)}
</div>
</TableCell>
<TableCell className="font-medium">{event.actor}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{event.action}
{event.target && (
<span className="ml-2 rounded bg-muted px-2 py-1 text-xs text-foreground">
{event.target}
</span>
)}
</TableCell>
<TableCell>
<Badge variant={SEVERITY_META[event.severity].variant} className="gap-1">
<ShieldWarning size={14} />
{SEVERITY_META[event.severity].label}
</Badge>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No audit events found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@/components/ui'
import type { UserRole } from '@/lib/level-types'
interface RoleEditorProps {
role: UserRole
onRoleChange: (role: UserRole) => void
isInstanceOwner?: boolean
onInstanceOwnerChange?: (value: boolean) => void
allowedRoles?: UserRole[]
}
const ROLE_INFO: Record<UserRole, { blurb: string; highlights: string[] }> = {
public: {
blurb: 'Read-only access for guest viewers.',
highlights: ['View public resources', 'No authentication needed'],
},
user: {
blurb: 'Standard workspace member with personal settings.',
highlights: ['Create content', 'Access shared libraries'],
},
moderator: {
blurb: 'Content moderator with collaboration tools.',
highlights: ['Manage comments', 'Resolve reports', 'Escalate to admins'],
},
admin: {
blurb: 'Tenant-level administrator controls.',
highlights: ['Invite users', 'Configure pages', 'Reset credentials'],
},
god: {
blurb: 'Power user with platform configuration access.',
highlights: ['Manage integrations', 'Run advanced scripts', 'Override safety flags'],
},
supergod: {
blurb: 'Instance owner with full control.',
highlights: ['Edit system settings', 'Manage tenants', 'Bypass feature gates'],
},
}
const roleLabel = (role: UserRole) => role.charAt(0).toUpperCase() + role.slice(1)
export function RoleEditor({
role,
onRoleChange,
isInstanceOwner,
onInstanceOwnerChange,
allowedRoles,
}: RoleEditorProps) {
const options = allowedRoles ?? (Object.keys(ROLE_INFO) as UserRole[])
return (
<Card>
<CardHeader>
<CardTitle>User role</CardTitle>
<CardDescription>Adjust access level and ownership flags.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={(value) => onRoleChange(value as UserRole)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a role" />
</SelectTrigger>
<SelectContent>
{options.map((value) => (
<SelectItem key={value} value={value}>
<div className="flex items-center justify-between gap-3">
<span>{roleLabel(value)}</span>
<Badge variant={value === 'supergod' || value === 'god' ? 'default' : 'secondary'}>
{value === 'public' ? 'Read only' : 'Level access'}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border p-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{roleLabel(role)}</p>
<p className="text-sm text-muted-foreground">{ROLE_INFO[role].blurb}</p>
</div>
<Badge variant="outline">{ROLE_INFO[role].highlights.length} capabilities</Badge>
</div>
<ul className="mt-3 grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
{ROLE_INFO[role].highlights.map((item) => (
<li key={item} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{onInstanceOwnerChange && (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="font-medium">Instance owner</p>
<p className="text-sm text-muted-foreground">
Grants access to backup, billing, and infrastructure settings.
</p>
</div>
<Switch checked={isInstanceOwner} onCheckedChange={onInstanceOwnerChange} />
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useMemo, useState } from 'react'
import {
Avatar,
AvatarFallback,
Badge,
Button,
Input,
Label,
ScrollArea,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui'
import { FunnelSimple, PencilSimple, Trash } from '@phosphor-icons/react'
import type { User, UserRole } from '@/lib/level-types'
interface UserListProps {
users: User[]
onEdit?: (user: User) => void
onDelete?: (user: User) => void
compact?: boolean
}
const ROLE_STYLES: Record<UserRole, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
public: { label: 'Public', variant: 'outline' },
user: { label: 'User', variant: 'outline' },
moderator: { label: 'Moderator', variant: 'secondary' },
admin: { label: 'Admin', variant: 'secondary' },
god: { label: 'God', variant: 'default' },
supergod: { label: 'Supergod', variant: 'default' },
}
function initials(value: string) {
return value
.split(' ')
.map((chunk) => chunk[0])
.join('')
.slice(0, 2)
.toUpperCase()
}
export function UserList({ users, onEdit, onDelete, compact }: UserListProps) {
const [query, setQuery] = useState('')
const [role, setRole] = useState<UserRole | 'all'>('all')
const filtered = useMemo(() => {
return users.filter((user) => {
const matchesQuery = `${user.username} ${user.email}`
.toLowerCase()
.includes(query.toLowerCase())
const matchesRole = role === 'all' || user.role === role
return matchesQuery && matchesRole
})
}, [users, query, role])
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label htmlFor="user-search">Search users</Label>
<Input
id="user-search"
placeholder="Search by name or email"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="flex items-center gap-2" htmlFor="role-filter">
<FunnelSimple size={16} /> Role filter
</Label>
<select
id="role-filter"
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={role}
onChange={(event) => setRole(event.target.value as UserRole | 'all')}
>
<option value="all">All roles</option>
{Object.entries(ROLE_STYLES).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
</div>
</div>
<ScrollArea className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-32">Role</TableHead>
<TableHead className="w-40">Joined</TableHead>
{(onEdit || onDelete) && <TableHead className="w-28 text-right">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((user) => (
<TableRow key={user.id} className={compact ? 'text-sm' : undefined}>
<TableCell className="flex items-center gap-3">
<Avatar className="h-9 w-9">
{user.profilePicture && <img alt={user.username} src={user.profilePicture} />}
<AvatarFallback>{initials(user.username)}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{user.username}</div>
<div className="text-xs text-muted-foreground">ID: {user.id}</div>
</div>
</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell>
<Badge variant={ROLE_STYLES[user.role]?.variant}>{ROLE_STYLES[user.role]?.label}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
{(onEdit || onDelete) && (
<TableCell className="flex justify-end gap-2">
{onEdit && (
<Button size="icon" variant="outline" onClick={() => onEdit(user)}>
<PencilSimple size={16} />
</Button>
)}
{onDelete && (
<Button size="icon" variant="ghost" onClick={() => onDelete(user)}>
<Trash size={16} />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No users match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</div>
)
}