mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-26 14:54:55 +00:00
Merge pull request #256 from johndoe6345789/codex/add-user-management-components
feat: add user management subcomponents
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user