Admin portal mobile responsiveness: tables, grids, and nav
- Tab nav: scroll isolation, icon-only on mobile, accessible titles - IAM table: hide 6 columns on mobile, responsive padding - User detail: responsive grid (1→2→3 cols), role select sizing - Dashboard: responsive stats grid, hide Actor/Target cols on mobile - Audit log: responsive column hiding and padding - Actions menu: role submenu repositions below trigger on mobile - Config: narrower filter select on mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
db16a07f68
commit
84b3083987
@ -25,7 +25,7 @@ export default function AdminDashboardPage() {
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
||||
{/* Stats grid */}
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
|
||||
<div className="grid gap-2.5 grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
@ -94,10 +94,10 @@ export default function AdminDashboardPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
When
|
||||
</th>
|
||||
</tr>
|
||||
@ -111,8 +111,8 @@ export default function AdminDashboardPage() {
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-2.5 font-medium">{entry.username}</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground">
|
||||
<td className="px-3 lg:px-5 py-2.5 font-medium">{entry.username}</td>
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground">
|
||||
{getRelativeTime(entry.last_login_at)}
|
||||
</td>
|
||||
</tr>
|
||||
@ -142,16 +142,16 @@ export default function AdminDashboardPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
Actor
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
Target
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
When
|
||||
</th>
|
||||
</tr>
|
||||
@ -165,7 +165,7 @@ export default function AdminDashboardPage() {
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-2.5">
|
||||
<td className="px-3 lg:px-5 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
|
||||
@ -175,15 +175,15 @@ export default function AdminDashboardPage() {
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs font-medium">
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs font-medium hidden sm:table-cell">
|
||||
{entry.actor_username ?? (
|
||||
<span className="text-muted-foreground italic">system</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground">
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground hidden sm:table-cell">
|
||||
{entry.target_username ?? '—'}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{getRelativeTime(entry.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -17,32 +17,33 @@ export default function AdminPortal() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Portal header with tab navigation */}
|
||||
<div className="shrink-0 border-b bg-card">
|
||||
<div className="px-4 md:px-6 h-16 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 mr-6">
|
||||
<div className="shrink-0 border-b bg-card overflow-hidden">
|
||||
<div className="px-3 md:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-2 mr-2 md:mr-6 shrink-0">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">Admin Portal</h1>
|
||||
<h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">Admin</h1>
|
||||
</div>
|
||||
|
||||
{/* Horizontal tab navigation */}
|
||||
<nav className="flex items-center gap-1 h-full">
|
||||
<nav className="flex items-center gap-1 h-full min-w-0 overflow-x-auto">
|
||||
{tabs.map(({ label, path, icon: Icon }) => {
|
||||
const isActive = location.pathname.startsWith(path);
|
||||
return (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px',
|
||||
'flex items-center gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
|
||||
isActive
|
||||
? 'text-accent border-accent'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -75,7 +75,7 @@ export default function ConfigPage() {
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Filter:</span>
|
||||
</div>
|
||||
<div className="w-52">
|
||||
<div className="w-36 sm:w-52">
|
||||
<Select
|
||||
value={filterAction}
|
||||
onChange={(e) => {
|
||||
@ -129,22 +129,22 @@ export default function ConfigPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
Actor
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
Target
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
IP
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
Detail
|
||||
</th>
|
||||
</tr>
|
||||
@ -158,15 +158,15 @@ export default function ConfigPage() {
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{getRelativeTime(entry.created_at)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs font-medium">
|
||||
<td className="px-3 lg:px-5 py-3 text-xs font-medium hidden sm:table-cell">
|
||||
{entry.actor_username ?? (
|
||||
<span className="text-muted-foreground italic">system</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<td className="px-3 lg:px-5 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
|
||||
@ -176,13 +176,13 @@ export default function ConfigPage() {
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground">
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground hidden sm:table-cell">
|
||||
{entry.target_username ?? '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground font-mono">
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground font-mono hidden lg:table-cell">
|
||||
{entry.ip_address ?? '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground max-w-xs truncate">
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground max-w-xs truncate hidden lg:table-cell">
|
||||
{entry.detail ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -125,7 +125,7 @@ export default function IAMPage() {
|
||||
|
||||
{/* User table */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between gap-3">
|
||||
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Users className="h-4 w-4 text-accent" />
|
||||
@ -139,12 +139,12 @@ export default function IAMPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
className="pl-8 h-8 w-48 text-xs"
|
||||
className="pl-8 h-8 w-32 sm:w-48 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create User
|
||||
<span className="hidden sm:inline">Create User</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@ -160,38 +160,38 @@ export default function IAMPage() {
|
||||
{searchQuery ? 'No users match your search.' : 'No users found.'}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
Umbral Name
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
MFA
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
Sessions
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-3 lg:px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
@ -211,17 +211,17 @@ export default function IAMPage() {
|
||||
)
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-3 font-medium">{user.username}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
<td className="px-3 lg:px-5 py-3 font-medium">{user.username}</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
{user.umbral_name || user.username}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
{user.email || '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<td className="px-3 lg:px-5 py-3">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<td className="px-3 lg:px-5 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
|
||||
@ -233,10 +233,10 @@ export default function IAMPage() {
|
||||
{user.is_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<td className="px-3 lg:px-5 py-3 hidden lg:table-cell">
|
||||
{user.totp_enabled ? (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
|
||||
On
|
||||
@ -249,13 +249,13 @@ export default function IAMPage() {
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums">
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs tabular-nums hidden lg:table-cell">
|
||||
{user.active_sessions}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
{getRelativeTime(user.created_at)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<td className="px-3 lg:px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -147,7 +147,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
|
||||
{roleSubmenuOpen && (
|
||||
<div
|
||||
className="absolute right-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
|
||||
className="absolute left-0 top-full sm:left-auto sm:right-full sm:top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
|
||||
onMouseEnter={() => setRoleSubmenuOpen(true)}
|
||||
onMouseLeave={() => setRoleSubmenuOpen(false)}
|
||||
>
|
||||
|
||||
@ -71,15 +71,15 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="col-span-1">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-1">
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
@ -109,9 +109,9 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
{/* User Information (read-only) */}
|
||||
<Card className="col-span-1">
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
@ -152,7 +152,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
</Card>
|
||||
|
||||
{/* Security & Permissions */}
|
||||
<Card className="col-span-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
@ -168,7 +168,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
<Select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(e.target.value as UserRole)}
|
||||
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[120px]"
|
||||
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[100px] sm:min-w-[120px]"
|
||||
disabled={updateRole.isPending}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
@ -221,7 +221,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
</Card>
|
||||
|
||||
{/* Sharing Stats */}
|
||||
<Card className="col-span-1">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user