1304 lines
58 KiB
TypeScript
1304 lines
58 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
OperationsRole,
|
|
SafeUserSummary,
|
|
SafeUserDetail,
|
|
} from './types';
|
|
|
|
interface OperationsUsersProps {
|
|
role: OperationsRole;
|
|
}
|
|
|
|
export function OperationsUsers({ role: _role }: OperationsUsersProps) {
|
|
const [users, setUsers] = useState<SafeUserSummary[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [totalCount, setTotalCount] = useState<number | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
// Filters
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [appliedSearch, setAppliedSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'suspended'>('all');
|
|
const [verifiedFilter, setVerifiedFilter] = useState<'all' | 'verified' | 'unverified'>('all');
|
|
|
|
// Selected User Detail Modal / Drawer
|
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
const [userDetail, setUserDetail] = useState<SafeUserDetail | null>(null);
|
|
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
|
const [detailError, setDetailError] = useState<string | null>(null);
|
|
|
|
// Action Confirmation Modal
|
|
const [actionType, setActionType] = useState<
|
|
'suspend' | 'reinstate' | 'revoke-sessions' | 'reset-password' | null
|
|
>(null);
|
|
const [actionReason, setActionReason] = useState('');
|
|
const [isSubmittingAction, setIsSubmittingAction] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [actionSuccessMessage, setActionSuccessMessage] = useState<string | null>(null);
|
|
|
|
// Add User Modal State
|
|
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
|
|
const [addEmail, setAddEmail] = useState('');
|
|
const [addDisplayName, setAddDisplayName] = useState('');
|
|
const [addPassword, setAddPassword] = useState('');
|
|
const [addRole, setAddRole] = useState<'user' | 'operator'>('user');
|
|
const [addEmailVerified, setAddEmailVerified] = useState(false);
|
|
const [addReason, setAddReason] = useState('Manual account provisioned by operator');
|
|
const [isSubmittingAddUser, setIsSubmittingAddUser] = useState(false);
|
|
const [addUserError, setAddUserError] = useState<string | null>(null);
|
|
|
|
async function handleAddUserSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (isSubmittingAddUser) return;
|
|
if (!addEmail.trim() || !addEmail.includes('@')) {
|
|
setAddUserError('Valid email is required.');
|
|
return;
|
|
}
|
|
if (addPassword.length < 8) {
|
|
setAddUserError('Password must be at least 8 characters.');
|
|
return;
|
|
}
|
|
if (addReason.trim().length < 3) {
|
|
setAddUserError('Reason must be at least 3 characters.');
|
|
return;
|
|
}
|
|
|
|
setIsSubmittingAddUser(true);
|
|
setAddUserError(null);
|
|
|
|
try {
|
|
const resp = await fetch('/api/ops/users', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
email: addEmail.trim(),
|
|
display_name: addDisplayName.trim() || null,
|
|
password: addPassword,
|
|
role: addRole,
|
|
email_verified: addEmailVerified,
|
|
reason: addReason.trim(),
|
|
}),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => null);
|
|
setAddUserError(data?.detail || 'Failed to create user');
|
|
setIsSubmittingAddUser(false);
|
|
return;
|
|
}
|
|
|
|
setIsAddUserOpen(false);
|
|
setAddEmail('');
|
|
setAddDisplayName('');
|
|
setAddPassword('');
|
|
setAddRole('user');
|
|
setAddEmailVerified(false);
|
|
setAddReason('Manual account provisioned by operator');
|
|
setActionSuccessMessage(`User ${addEmail.trim()} created successfully.`);
|
|
void fetchUsers();
|
|
} catch {
|
|
setAddUserError('Network error connecting to Operations platform.');
|
|
} finally {
|
|
setIsSubmittingAddUser(false);
|
|
}
|
|
}
|
|
|
|
// Fetch users list
|
|
const fetchUsers = useCallback(
|
|
async (cursor?: string) => {
|
|
if (cursor) {
|
|
setIsLoadingMore(true);
|
|
} else {
|
|
setIsLoading(true);
|
|
setErrorMessage(null);
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (appliedSearch.trim()) params.set('q', appliedSearch.trim());
|
|
if (statusFilter !== 'all') params.set('status_filter', statusFilter);
|
|
if (verifiedFilter !== 'all') params.set('verified_filter', verifiedFilter);
|
|
if (cursor) params.set('cursor', cursor);
|
|
|
|
try {
|
|
const response = await fetch(`/api/ops/users?${params.toString()}`, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
setErrorMessage('Your Operations session has expired.');
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
setErrorMessage('You do not have authority to view users.');
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
setErrorMessage('Failed to load users from platform.');
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const items: SafeUserSummary[] = Array.isArray(data?.items) ? data.items : [];
|
|
|
|
if (cursor) {
|
|
setUsers((prev) => [...prev, ...items]);
|
|
} else {
|
|
setUsers(items);
|
|
}
|
|
setNextCursor(data?.next_cursor ?? null);
|
|
if (typeof data?.total === 'number') {
|
|
setTotalCount(data.total);
|
|
}
|
|
} catch {
|
|
setErrorMessage('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsLoadingMore(false);
|
|
}
|
|
},
|
|
[appliedSearch, statusFilter, verifiedFilter]
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
}, [fetchUsers]);
|
|
|
|
// Fetch individual user detail
|
|
const fetchUserDetail = useCallback(async (targetId: string) => {
|
|
setIsLoadingDetail(true);
|
|
setDetailError(null);
|
|
try {
|
|
const response = await fetch(`/api/ops/users/${encodeURIComponent(targetId)}`, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
setDetailError('Your Operations session has expired.');
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
setDetailError('You do not have authority to view this user.');
|
|
return;
|
|
}
|
|
if (response.status === 404) {
|
|
setDetailError('User profile not found.');
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
setDetailError('Failed to load user details.');
|
|
return;
|
|
}
|
|
|
|
const data = (await response.json()) as SafeUserDetail;
|
|
setUserDetail(data);
|
|
} catch {
|
|
setDetailError('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsLoadingDetail(false);
|
|
}
|
|
}, []);
|
|
|
|
function handleSelectUser(targetId: string) {
|
|
setSelectedUserId(targetId);
|
|
setActionSuccessMessage(null);
|
|
void fetchUserDetail(targetId);
|
|
}
|
|
|
|
function handleCloseDetail() {
|
|
setSelectedUserId(null);
|
|
setUserDetail(null);
|
|
setDetailError(null);
|
|
setActionType(null);
|
|
setActionReason('');
|
|
setActionError(null);
|
|
setActionSuccessMessage(null);
|
|
}
|
|
|
|
function handleOpenAction(
|
|
type: 'suspend' | 'reinstate' | 'revoke-sessions' | 'reset-password'
|
|
) {
|
|
setActionType(type);
|
|
setActionReason('');
|
|
setActionError(null);
|
|
}
|
|
|
|
function handleCloseActionModal() {
|
|
if (isSubmittingAction) return;
|
|
setActionType(null);
|
|
setActionReason('');
|
|
setActionError(null);
|
|
}
|
|
|
|
// Handle Action Submit
|
|
async function handleExecuteAction(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!selectedUserId || !actionType) return;
|
|
if (actionReason.trim().length < 3) {
|
|
setActionError('Reason must be at least 3 characters long.');
|
|
return;
|
|
}
|
|
|
|
setIsSubmittingAction(true);
|
|
setActionError(null);
|
|
|
|
try {
|
|
let endpoint = '';
|
|
let bodyData: Record<string, unknown> = {};
|
|
|
|
if (actionType === 'suspend') {
|
|
endpoint = `/api/ops/users/${encodeURIComponent(selectedUserId)}/status`;
|
|
bodyData = { is_active: false, reason: actionReason.trim() };
|
|
} else if (actionType === 'reinstate') {
|
|
endpoint = `/api/ops/users/${encodeURIComponent(selectedUserId)}/status`;
|
|
bodyData = { is_active: true, reason: actionReason.trim() };
|
|
} else if (actionType === 'revoke-sessions') {
|
|
endpoint = `/api/ops/users/${encodeURIComponent(selectedUserId)}/revoke-sessions`;
|
|
bodyData = { reason: actionReason.trim() };
|
|
} else if (actionType === 'reset-password') {
|
|
endpoint = `/api/ops/users/${encodeURIComponent(selectedUserId)}/reset-password`;
|
|
bodyData = { reason: actionReason.trim() };
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify(bodyData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => null);
|
|
setActionError(errorData?.detail ?? 'Action failed to execute.');
|
|
setIsSubmittingAction(false);
|
|
return;
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Update state locally
|
|
if (actionType === 'suspend' || actionType === 'reinstate') {
|
|
const newIsActive = actionType === 'reinstate';
|
|
setUserDetail((prev) =>
|
|
prev ? { ...prev, is_active: newIsActive } : null
|
|
);
|
|
setUsers((prev) =>
|
|
prev.map((u) =>
|
|
u.id === selectedUserId ? { ...u, is_active: newIsActive } : u
|
|
)
|
|
);
|
|
setActionSuccessMessage(
|
|
newIsActive
|
|
? 'Account reinstated successfully.'
|
|
: 'Account suspended and active sessions revoked.'
|
|
);
|
|
} else if (actionType === 'revoke-sessions') {
|
|
setUserDetail((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
sessions: prev.sessions.map((s) => ({ ...s, is_revoked: true })),
|
|
}
|
|
: null
|
|
);
|
|
setUsers((prev) =>
|
|
prev.map((u) =>
|
|
u.id === selectedUserId ? { ...u, active_sessions_count: 0 } : u
|
|
)
|
|
);
|
|
setActionSuccessMessage('All active user sessions revoked.');
|
|
} else if (actionType === 'reset-password') {
|
|
setActionSuccessMessage(
|
|
`Password reset link dispatched. Token valid until ${
|
|
result.expires_at ? formatDate(result.expires_at) : '24 hours'
|
|
}.`
|
|
);
|
|
}
|
|
|
|
setActionType(null);
|
|
setActionReason('');
|
|
} catch {
|
|
setActionError('Network error executing operation.');
|
|
} finally {
|
|
setIsSubmittingAction(false);
|
|
}
|
|
}
|
|
|
|
function handleSearchSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setAppliedSearch(searchQuery);
|
|
}
|
|
|
|
function handleClearSearch() {
|
|
setSearchQuery('');
|
|
setAppliedSearch('');
|
|
}
|
|
|
|
function formatDate(isoString?: string | null) {
|
|
if (!isoString) return 'Never';
|
|
try {
|
|
const d = new Date(isoString);
|
|
return d.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
} catch {
|
|
return isoString;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-5 border-b border-outline-variant/30">
|
|
<div>
|
|
<span className="text-xs font-mono text-primary-fixed-dim font-semibold">
|
|
User Management & Governance
|
|
</span>
|
|
<div className="flex items-center gap-3 mt-1.5">
|
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight">
|
|
User Management
|
|
</h1>
|
|
{totalCount !== null && (
|
|
<span className="px-2.5 py-1 rounded-full text-xs font-mono font-semibold bg-surface-container-high text-on-surface-variant border border-outline-variant/30">
|
|
{totalCount} Total
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-on-surface-variant mt-1.5">
|
|
Search accounts, inspect verified credentials, audit sessions, and enforce status boundaries.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsAddUserOpen(true);
|
|
setAddUserError(null);
|
|
}}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-primary-container text-on-primary-container hover:opacity-90 text-xs sm:text-sm font-semibold shadow-sm transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
|
title="Create a new user account"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">person_add</span>
|
|
<span>Add User</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchUsers()}
|
|
disabled={isLoading}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-container-high hover:bg-surface-bright text-primary-container border border-primary-container/40 text-xs sm:text-sm font-semibold shadow-sm transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
|
title="Refresh users list"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[18px] ${
|
|
isLoading ? 'animate-spin' : ''
|
|
}`}
|
|
>
|
|
refresh
|
|
</span>
|
|
<span>{isLoading ? 'Checking...' : 'Refresh'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter & Search Bar */}
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-surface-container-low p-4 sm:p-5 rounded-2xl border border-outline-variant/30 shadow-sm">
|
|
{/* Search Input */}
|
|
<form onSubmit={handleSearchSubmit} className="flex items-center gap-2.5 flex-1 max-w-xl">
|
|
<div className="relative flex-1">
|
|
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-[20px]">
|
|
search
|
|
</span>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search user by email or account ID..."
|
|
className="w-full bg-surface-container pl-10 pr-9 py-2.5 rounded-xl border border-outline-variant/30 text-xs sm:text-sm font-mono text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClearSearch}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface"
|
|
title="Clear search"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2.5 bg-primary-container text-on-primary-container rounded-xl text-xs sm:text-sm font-bold hover:bg-primary-container/90 transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none shrink-0 cursor-pointer"
|
|
>
|
|
Search
|
|
</button>
|
|
</form>
|
|
|
|
{/* Filter Pills */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-outline font-medium">Status:</span>
|
|
<div className="inline-flex rounded-lg p-0.5 bg-surface-container border border-outline-variant/30">
|
|
{(['all', 'active', 'suspended'] as const).map((st) => (
|
|
<button
|
|
key={st}
|
|
type="button"
|
|
onClick={() => setStatusFilter(st)}
|
|
className={`px-2.5 py-1 rounded-md text-[11px] font-medium capitalize transition-all ${
|
|
statusFilter === st
|
|
? 'bg-surface-container-highest text-primary-container font-bold shadow-sm'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
{st}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Verification Filter */}
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-outline font-medium">Verified:</span>
|
|
<div className="inline-flex rounded-lg p-0.5 bg-surface-container border border-outline-variant/30">
|
|
{(['all', 'verified', 'unverified'] as const).map((vf) => (
|
|
<button
|
|
key={vf}
|
|
type="button"
|
|
onClick={() => setVerifiedFilter(vf)}
|
|
className={`px-2.5 py-1 rounded-md text-[11px] font-medium capitalize transition-all ${
|
|
verifiedFilter === vf
|
|
? 'bg-surface-container-highest text-primary-container font-bold shadow-sm'
|
|
: 'text-on-surface-variant hover:text-on-surface'
|
|
}`}
|
|
>
|
|
{vf}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Banner */}
|
|
{errorMessage && (
|
|
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
|
|
<span className="material-symbols-outlined text-[18px]">error</span>
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Users Content: Desktop Table & Mobile Cards */}
|
|
{isLoading ? (
|
|
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
|
|
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
|
|
progress_activity
|
|
</span>
|
|
<span className="text-xs font-medium">Loading platform users...</span>
|
|
</div>
|
|
) : users.length === 0 ? (
|
|
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
|
|
<span className="material-symbols-outlined text-3xl text-outline-variant">
|
|
group_off
|
|
</span>
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-semibold text-on-surface">No users found</p>
|
|
<p className="text-[11px] text-outline">
|
|
{appliedSearch || statusFilter !== 'all' || verifiedFilter !== 'all'
|
|
? 'No users match your search and filter criteria.'
|
|
: 'No users registered in this environment.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Desktop Table */}
|
|
<div className="hidden md:block overflow-hidden rounded-2xl border border-outline-variant/30 bg-surface-container-lowest shadow-sm">
|
|
<table className="w-full text-left text-xs sm:text-sm border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-outline-variant/20 bg-surface-container-low text-xs font-mono text-outline">
|
|
<th className="py-4 px-5 sm:px-6 font-semibold">User / ID</th>
|
|
<th className="py-4 px-5 sm:px-6 font-semibold">Email Status</th>
|
|
<th className="py-4 px-5 sm:px-6 font-semibold">Account Status</th>
|
|
<th className="py-4 px-5 sm:px-6 font-semibold">App Version / Device</th>
|
|
<th className="py-4 px-5 sm:px-6 font-semibold">Last Sync</th>
|
|
<th className="py-4 px-5 sm:px-6 font-semibold">Sessions</th>
|
|
<th className="py-4 px-5 sm:px-6 font-semibold">Rides</th>
|
|
<th className="py-4 px-5 sm:px-6 font-semibold text-right">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-outline-variant/10 text-on-surface">
|
|
{users.map((u) => (
|
|
<tr
|
|
key={u.id}
|
|
onClick={() => handleSelectUser(u.id)}
|
|
className="hover:bg-surface-container/50 cursor-pointer transition-colors group focus-within:bg-surface-container"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleSelectUser(u.id);
|
|
}
|
|
}}
|
|
>
|
|
<td className="py-4 px-5 sm:px-6">
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-semibold text-on-surface group-hover:text-primary-container transition-colors">
|
|
{u.email}
|
|
</span>
|
|
<span className="font-mono text-xs text-outline truncate max-w-sm">
|
|
{u.id}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="py-4 px-5 sm:px-6">
|
|
{u.email_verified ? (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-mono font-medium bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
<span className="material-symbols-outlined text-[14px]">check_circle</span>
|
|
<span>Verified</span>
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-mono font-medium bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
|
<span className="material-symbols-outlined text-[14px]">schedule</span>
|
|
<span>Unverified</span>
|
|
</span>
|
|
)}
|
|
</td>
|
|
|
|
<td className="py-4 px-5 sm:px-6">
|
|
{u.is_active ? (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-mono font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
<span>Active</span>
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-mono font-semibold bg-error/15 text-error border border-error/30">
|
|
<span>Suspended</span>
|
|
</span>
|
|
)}
|
|
</td>
|
|
|
|
<td className="py-4 px-5 sm:px-6 text-xs sm:text-sm font-mono text-on-surface-variant">
|
|
{u.latest_app_version ?? (
|
|
<span className="text-outline italic">Unknown</span>
|
|
)}
|
|
</td>
|
|
|
|
<td className="py-4 px-5 sm:px-6 text-xs sm:text-sm font-mono text-outline">
|
|
{formatDate(u.last_sync_at)}
|
|
</td>
|
|
|
|
<td className="py-4 px-5 sm:px-6">
|
|
<span className="px-2.5 py-1 rounded-lg font-mono text-xs font-semibold bg-surface-container-high border border-outline-variant/30 text-on-surface">
|
|
{u.active_sessions_count} active
|
|
</span>
|
|
</td>
|
|
|
|
<td className="py-4 px-5 sm:px-6 font-mono font-medium text-xs sm:text-sm">
|
|
{u.total_rides}
|
|
</td>
|
|
|
|
<td className="py-4 px-5 sm:px-6 text-right">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleSelectUser(u.id);
|
|
}}
|
|
className="px-3.5 py-1.5 rounded-xl bg-surface-container-high hover:bg-surface-bright text-xs font-semibold text-primary-container border border-primary-container/40 transition-colors shadow-sm"
|
|
>
|
|
Manage
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Stacked Cards */}
|
|
<div className="md:hidden space-y-3">
|
|
{users.map((u) => (
|
|
<button
|
|
key={u.id}
|
|
type="button"
|
|
onClick={() => handleSelectUser(u.id)}
|
|
className="w-full text-left p-4 rounded-xl border border-outline-variant/20 bg-surface-container-lowest hover:bg-surface-container-low transition-colors shadow-sm focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none space-y-2.5"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="space-y-0.5">
|
|
<p className="font-semibold text-xs text-on-surface">
|
|
{u.email}
|
|
</p>
|
|
<p className="font-mono text-[10px] text-outline truncate max-w-[200px]">
|
|
{u.id}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{u.is_active ? (
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
ACTIVE
|
|
</span>
|
|
) : (
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono font-bold bg-error/15 text-error border border-error/30">
|
|
SUSPENDED
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-[11px] pt-2 border-t border-outline-variant/10">
|
|
<div>
|
|
<span className="text-outline text-[10px] block">App Version</span>
|
|
<span className="font-mono text-on-surface-variant font-medium">
|
|
{u.latest_app_version ?? 'Unknown'}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-outline text-[10px] block">Last Sync</span>
|
|
<span className="font-mono text-on-surface-variant">
|
|
{formatDate(u.last_sync_at)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-outline text-[10px] block">Active Sessions</span>
|
|
<span className="font-mono text-on-surface-variant font-semibold">
|
|
{u.active_sessions_count}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-outline text-[10px] block">Total Rides</span>
|
|
<span className="font-mono text-on-surface-variant font-semibold">
|
|
{u.total_rides}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between text-[10px] font-mono pt-1 text-primary-fixed-dim">
|
|
<span>{u.email_verified ? 'Verified Email' : 'Unverified Email'}</span>
|
|
<span>Manage Profile →</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Cursor Pagination */}
|
|
{nextCursor && (
|
|
<div className="pt-2 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchUsers(nextCursor)}
|
|
disabled={isLoadingMore}
|
|
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
{isLoadingMore ? 'Loading More...' : 'Load More Users'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* User Detail Drawer / Modal */}
|
|
{selectedUserId && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="user-detail-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center sm:justify-end bg-black/60 backdrop-blur-sm transition-opacity"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape' && !actionType) {
|
|
handleCloseDetail();
|
|
}
|
|
}}
|
|
>
|
|
<div className="w-full sm:max-w-2xl lg:max-w-3xl h-full bg-surface-container-lowest border-l border-outline-variant/30 shadow-2xl flex flex-col overflow-hidden animate-in slide-in-from-right duration-200">
|
|
{/* Drawer Header */}
|
|
<div className="p-4 sm:p-5 border-b border-outline-variant/20 bg-surface-container-low flex items-start justify-between gap-3">
|
|
<div className="space-y-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-primary-container text-[20px]">
|
|
account_circle
|
|
</span>
|
|
<h2
|
|
id="user-detail-title"
|
|
className="text-sm font-bold text-on-surface truncate"
|
|
>
|
|
{userDetail?.email ?? 'User Profile'}
|
|
</h2>
|
|
</div>
|
|
<p className="font-mono text-[10px] text-outline truncate">
|
|
ID: {selectedUserId}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseDetail}
|
|
className="p-1 rounded-lg text-outline hover:text-on-surface hover:bg-surface-container transition-colors"
|
|
title="Close drawer"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Success Feedback Alert */}
|
|
{actionSuccessMessage && (
|
|
<div className="mx-4 mt-4 p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-xs flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[16px]">check_circle</span>
|
|
<span>{actionSuccessMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detail Error */}
|
|
{detailError && (
|
|
<div className="mx-4 mt-4 p-3 rounded-lg bg-error/10 border border-error/30 text-error text-xs flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[16px]">error</span>
|
|
<span>{detailError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Drawer Body */}
|
|
<div className="flex-1 overflow-y-auto p-4 sm:p-5 space-y-5">
|
|
{isLoadingDetail ? (
|
|
<div className="p-12 text-center text-outline flex flex-col items-center justify-center gap-3">
|
|
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
|
|
progress_activity
|
|
</span>
|
|
<span className="text-xs font-medium">Loading user details...</span>
|
|
</div>
|
|
) : userDetail ? (
|
|
<>
|
|
{/* Status Badges & Quick Facts */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 p-3 rounded-xl bg-surface-container-low border border-outline-variant/20">
|
|
<div className="space-y-0.5">
|
|
<span className="text-[10px] text-outline font-medium">Account</span>
|
|
<div>
|
|
{userDetail.is_active ? (
|
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
Active
|
|
</span>
|
|
) : (
|
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold bg-error/15 text-error border border-error/30">
|
|
Suspended
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-0.5">
|
|
<span className="text-[10px] text-outline font-medium">Email</span>
|
|
<div>
|
|
{userDetail.email_verified ? (
|
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-mono font-medium bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
Verified
|
|
</span>
|
|
) : (
|
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-mono font-medium bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
|
Unverified
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-0.5">
|
|
<span className="text-[10px] text-outline font-medium">Role</span>
|
|
<div className="font-mono text-xs font-semibold text-on-surface capitalize">
|
|
{userDetail.role}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-0.5">
|
|
<span className="text-[10px] text-outline font-medium">Subscription</span>
|
|
<div className="font-mono text-xs font-semibold text-primary-container capitalize">
|
|
{userDetail.subscription?.plan ?? 'Free'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Operational Management Actions */}
|
|
<div className="p-3.5 rounded-xl border border-outline-variant/30 bg-surface-container-low/60 space-y-3">
|
|
<h3 className="text-xs font-semibold text-outline font-mono">
|
|
Administrative Actions
|
|
</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{userDetail.is_active ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenAction('suspend')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-error/15 text-error border border-error/30 text-xs font-bold hover:bg-error/25 transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">block</span>
|
|
<span>Suspend Account</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenAction('reinstate')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-xs font-bold hover:bg-emerald-500/25 transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">check_circle</span>
|
|
<span>Reinstate Account</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenAction('revoke-sessions')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500/15 text-amber-400 border border-amber-500/30 text-xs font-bold hover:bg-amber-500/25 transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">logout</span>
|
|
<span>Revoke All Sessions</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenAction('reset-password')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container-high text-on-surface border border-outline-variant/30 text-xs font-bold hover:bg-surface-container-highest transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">lock_reset</span>
|
|
<span>Send Password Reset</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Account Metadata */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-outline font-mono">
|
|
Account Timestamps
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs bg-surface-container-lowest p-3 rounded-lg border border-outline-variant/20">
|
|
<div>
|
|
<span className="text-[10px] text-outline block">Registered</span>
|
|
<span className="font-mono text-on-surface">
|
|
{formatDate(userDetail.created_at)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-[10px] text-outline block">Last Updated</span>
|
|
<span className="font-mono text-on-surface">
|
|
{formatDate(userDetail.updated_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rides Summary */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-outline font-mono">
|
|
Rides Summary
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-3 p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20 text-xs">
|
|
<div>
|
|
<span className="text-[10px] text-outline block">Total Rides Recorded</span>
|
|
<span className="font-mono text-sm font-bold text-primary-container">
|
|
{userDetail.ride_summary.total_rides}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-[10px] text-outline block">Last Ride Timestamp</span>
|
|
<span className="font-mono text-xs text-on-surface">
|
|
{formatDate(userDetail.ride_summary.last_ride_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Device & Session History */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-semibold text-outline font-mono">
|
|
Active Sessions & Devices ({userDetail.sessions.length})
|
|
</h3>
|
|
</div>
|
|
{userDetail.sessions.length === 0 ? (
|
|
<p className="text-xs text-outline italic p-3 bg-surface-container-lowest rounded-lg border border-outline-variant/20">
|
|
No recorded sessions for this user.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{userDetail.sessions.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20 flex items-center justify-between gap-3 text-xs"
|
|
>
|
|
<div className="space-y-0.5 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="material-symbols-outlined text-[16px] text-outline">
|
|
devices
|
|
</span>
|
|
<span className="font-semibold text-on-surface truncate">
|
|
{s.device_name ?? 'Unknown Device'}
|
|
</span>
|
|
</div>
|
|
<p className="font-mono text-[10px] text-outline">
|
|
Created: {formatDate(s.created_at)} | Last used: {formatDate(s.last_used_at)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
{s.is_revoked ? (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-surface-container text-outline border border-outline-variant/20">
|
|
Revoked
|
|
</span>
|
|
) : (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent Audit Log */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-outline font-mono">
|
|
Recent Activity & Security Audit ({userDetail.recent_audit.length})
|
|
</h3>
|
|
{userDetail.recent_audit.length === 0 ? (
|
|
<p className="text-xs text-outline italic p-3 bg-surface-container-lowest rounded-lg border border-outline-variant/20">
|
|
No audit events recorded for this user.
|
|
</p>
|
|
) : (
|
|
<div className="divide-y divide-outline-variant/10 rounded-lg bg-surface-container-lowest border border-outline-variant/20 overflow-hidden">
|
|
{userDetail.recent_audit.map((evt, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="p-2.5 flex items-center justify-between gap-2 text-[11px]"
|
|
>
|
|
<div className="space-y-0.5">
|
|
<span className="font-mono font-semibold text-on-surface">
|
|
{evt.action}
|
|
</span>
|
|
{evt.ip && (
|
|
<span className="text-[10px] font-mono text-outline block">
|
|
IP: {evt.ip}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="font-mono text-[10px] text-outline shrink-0">
|
|
{formatDate(evt.timestamp)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirmation Modal with Reason Input */}
|
|
{actionType && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="confirm-action-title"
|
|
className="fixed inset-0 z-60 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape' && !isSubmittingAction) {
|
|
handleCloseActionModal();
|
|
}
|
|
}}
|
|
>
|
|
<div className="w-full max-w-md bg-surface-container-lowest border border-outline-variant/40 rounded-2xl p-5 shadow-2xl space-y-4 animate-in zoom-in-95 duration-150">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`material-symbols-outlined text-[24px] ${
|
|
actionType === 'suspend'
|
|
? 'text-error'
|
|
: actionType === 'revoke-sessions'
|
|
? 'text-amber-400'
|
|
: 'text-primary-container'
|
|
}`}
|
|
>
|
|
{actionType === 'suspend'
|
|
? 'warning'
|
|
: actionType === 'revoke-sessions'
|
|
? 'logout'
|
|
: actionType === 'reset-password'
|
|
? 'lock_reset'
|
|
: 'check_circle'}
|
|
</span>
|
|
<h3
|
|
id="confirm-action-title"
|
|
className="text-sm font-bold text-on-surface font-headline-sm"
|
|
>
|
|
{actionType === 'suspend' && 'Suspend User Account'}
|
|
{actionType === 'reinstate' && 'Reinstate User Account'}
|
|
{actionType === 'revoke-sessions' && 'Revoke All User Sessions'}
|
|
{actionType === 'reset-password' && 'Dispatch Password Reset'}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseActionModal}
|
|
disabled={isSubmittingAction}
|
|
className="text-outline hover:text-on-surface"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-xs text-on-surface-variant">
|
|
{actionType === 'suspend' &&
|
|
'Suspending this user will immediately invalidate all active sessions, block further authentication, and halt telemetry sync until reinstated.'}
|
|
{actionType === 'reinstate' &&
|
|
'Reinstating this account will allow the user to log in and resume normal application activity.'}
|
|
{actionType === 'revoke-sessions' &&
|
|
'Revoking sessions terminates all active refresh tokens and browser/mobile sessions across all devices.'}
|
|
{actionType === 'reset-password' &&
|
|
'Generating a secure reset token creates an emergency password reset link valid for 24 hours.'}
|
|
</p>
|
|
|
|
{actionError && (
|
|
<div className="p-3 rounded-lg bg-error/10 border border-error/30 text-error text-xs flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[16px]">error</span>
|
|
<span>{actionError}</span>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleExecuteAction} className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="action-reason-input"
|
|
className="block text-xs font-semibold text-on-surface"
|
|
>
|
|
Reason for Audit Record <span className="text-error">*</span>
|
|
</label>
|
|
<textarea
|
|
id="action-reason-input"
|
|
rows={3}
|
|
value={actionReason}
|
|
onChange={(e) => setActionReason(e.target.value)}
|
|
placeholder="State the administrative justification for this security action (min 3 characters)..."
|
|
className="w-full bg-surface-container p-3 rounded-lg border border-outline-variant/30 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-none"
|
|
disabled={isSubmittingAction}
|
|
required
|
|
/>
|
|
<div className="flex justify-between text-[10px] text-outline font-mono">
|
|
<span>Minimum 3 characters required</span>
|
|
<span>{actionReason.trim().length} chars</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-outline-variant/20">
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseActionModal}
|
|
disabled={isSubmittingAction}
|
|
className="px-3 py-1.5 rounded-lg bg-surface-container text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmittingAction || actionReason.trim().length < 3}
|
|
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
actionType === 'suspend'
|
|
? 'bg-error text-on-error hover:bg-error/90'
|
|
: actionType === 'revoke-sessions'
|
|
? 'bg-amber-500 text-black hover:bg-amber-400'
|
|
: 'bg-primary-container text-on-primary-container hover:bg-primary-container/90'
|
|
}`}
|
|
>
|
|
{isSubmittingAction && (
|
|
<span className="material-symbols-outlined text-[14px] animate-spin">
|
|
progress_activity
|
|
</span>
|
|
)}
|
|
<span>
|
|
{actionType === 'suspend' && 'Confirm Suspension'}
|
|
{actionType === 'reinstate' && 'Confirm Reinstatement'}
|
|
{actionType === 'revoke-sessions' && 'Revoke Sessions'}
|
|
{actionType === 'reset-password' && 'Dispatch Reset'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add User Modal */}
|
|
{isAddUserOpen && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="add-user-title"
|
|
className="fixed inset-0 z-60 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape' && !isSubmittingAddUser) {
|
|
setIsAddUserOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className="w-full max-w-lg bg-surface-container-lowest border border-outline-variant/40 rounded-2xl p-6 shadow-2xl space-y-4 animate-in zoom-in-95 duration-150">
|
|
<div className="flex items-start justify-between gap-2 border-b border-outline-variant/20 pb-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-8 h-8 rounded-lg bg-surface-container-high border border-outline-variant/30 flex items-center justify-center text-primary-container">
|
|
<span className="material-symbols-outlined text-[20px]">person_add</span>
|
|
</div>
|
|
<div>
|
|
<h3 id="add-user-title" className="text-base font-bold text-on-surface font-headline-sm">
|
|
Add New User Account
|
|
</h3>
|
|
<p className="text-xs text-on-surface-variant">
|
|
Provision credentials with audit tracking and role assignment.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsAddUserOpen(false)}
|
|
disabled={isSubmittingAddUser}
|
|
className="text-outline hover:text-on-surface p-1 rounded-lg"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleAddUserSubmit} className="space-y-4">
|
|
{addUserError && (
|
|
<div className="p-3 rounded-xl bg-error-container/20 border border-error/30 text-error text-xs flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[16px]">error</span>
|
|
<span>{addUserError}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-xs font-mono font-medium text-outline mb-1">
|
|
Email Address *
|
|
</label>
|
|
<input
|
|
type="email"
|
|
required
|
|
value={addEmail}
|
|
onChange={(e) => setAddEmail(e.target.value)}
|
|
placeholder="rider@example.com"
|
|
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-mono font-medium text-outline mb-1">
|
|
Display Name (Optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={addDisplayName}
|
|
onChange={(e) => setAddDisplayName(e.target.value)}
|
|
placeholder="Alex Rider"
|
|
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-mono font-medium text-outline mb-1">
|
|
Initial Password * (min 8 characters)
|
|
</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
minLength={8}
|
|
value={addPassword}
|
|
onChange={(e) => setAddPassword(e.target.value)}
|
|
placeholder="••••••••••••"
|
|
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-mono font-medium text-outline mb-1">
|
|
Account Role *
|
|
</label>
|
|
<select
|
|
value={addRole}
|
|
onChange={(e) => setAddRole(e.target.value as 'user' | 'operator')}
|
|
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
<option value="user">User (Standard Rider)</option>
|
|
<option value="operator">Operator (Staff / Console)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 pt-6">
|
|
<input
|
|
type="checkbox"
|
|
id="addEmailVerified"
|
|
checked={addEmailVerified}
|
|
onChange={(e) => setAddEmailVerified(e.target.checked)}
|
|
className="rounded border-outline-variant/40 bg-surface-container text-primary-container focus:ring-primary-container h-4 w-4"
|
|
/>
|
|
<label htmlFor="addEmailVerified" className="text-xs font-mono text-on-surface cursor-pointer">
|
|
Mark Email Verified
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-mono font-medium text-outline mb-1">
|
|
Reason for Account Creation (Audit Log) *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={addReason}
|
|
onChange={(e) => setAddReason(e.target.value)}
|
|
placeholder="Reason / ticket reference..."
|
|
className="w-full bg-surface-container px-3 py-2 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 pt-3 border-t border-outline-variant/20">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsAddUserOpen(false)}
|
|
disabled={isSubmittingAddUser}
|
|
className="px-4 py-2 rounded-lg text-xs font-semibold text-on-surface-variant hover:bg-surface-container-high transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmittingAddUser}
|
|
className="px-4 py-2 rounded-lg text-xs font-semibold bg-primary-container text-on-primary-container hover:opacity-90 transition-all flex items-center gap-2"
|
|
>
|
|
{isSubmittingAddUser && (
|
|
<span className="material-symbols-outlined text-[14px] animate-spin">
|
|
progress_activity
|
|
</span>
|
|
)}
|
|
<span>Create User</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|