it0/it0-web-admin/src/app/(admin)/users/[id]/page.tsx

781 lines
28 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter, useParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface UserDetail {
id: string;
displayName: string;
email: string;
role: 'admin' | 'operator' | 'viewer';
tenantId: string;
tenantName: string;
status: 'active' | 'disabled';
lastLoginAt: string | null;
createdAt: string;
updatedAt: string;
}
interface ActivityEntry {
id: string;
action: string;
resource: string;
details: string;
ipAddress: string;
createdAt: string;
}
interface ActivityResponse {
data: ActivityEntry[];
total: number;
}
interface EditFormData {
displayName: string;
role: 'admin' | 'operator' | 'viewer';
status: 'active' | 'disabled';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'Never';
try {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return dateStr;
}
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
try {
return new Date(dateStr).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
}
function formatRelativeTime(dateStr: string): string {
try {
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 0) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
} catch {
return dateStr;
}
}
// ---------------------------------------------------------------------------
// Role badge
// ---------------------------------------------------------------------------
function RoleBadge({ role }: { role: UserDetail['role'] }) {
const styles: Record<UserDetail['role'], string> = {
admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
operator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
viewer: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300',
};
return (
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
styles[role],
)}
>
{role}
</span>
);
}
// ---------------------------------------------------------------------------
// Status badge
// ---------------------------------------------------------------------------
function StatusBadge({ status }: { status: UserDetail['status'] }) {
const styles: Record<UserDetail['status'], string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
disabled: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
};
return (
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
styles[status],
)}
>
{status}
</span>
);
}
// ---------------------------------------------------------------------------
// Activity action badge
// ---------------------------------------------------------------------------
function ActionBadge({ action }: { action: string }) {
const map: Record<string, string> = {
login: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
logout: 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300',
create: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
update: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
delete: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
execute: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
};
const style = map[action.toLowerCase()] ?? 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-300';
return (
<span
className={cn(
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium capitalize',
style,
)}
>
{action}
</span>
);
}
// ---------------------------------------------------------------------------
// Info row component
// ---------------------------------------------------------------------------
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4 py-2">
<dt className="text-sm text-muted-foreground sm:w-36 shrink-0">{label}</dt>
<dd className="text-sm font-medium">{value || '--'}</dd>
</div>
);
}
// ---------------------------------------------------------------------------
// Delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteDialog({
open,
userName,
deleting,
onClose,
onConfirm,
}: {
open: boolean;
userName: string;
deleting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">{t('deleteDialog.title')}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('deleteDialog.message')}
</p>
<div className="p-3 mb-4 rounded-md bg-muted text-xs text-muted-foreground">
{t('deleteDialog.warning')}
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={deleting}
>
{tc('cancel')}
</button>
<button
onClick={onConfirm}
disabled={deleting}
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
>
{deleting ? tc('deleting') : tc('delete')}
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Reset password confirmation dialog
// ---------------------------------------------------------------------------
function ResetPasswordDialog({
open,
userName,
resetting,
success,
onClose,
onConfirm,
}: {
open: boolean;
userName: string;
resetting: boolean;
success: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
<h2 className="text-lg font-semibold mb-2">{t('detail.resetPasswordDialog.title')}</h2>
{success ? (
<>
<div className="p-3 mb-4 rounded-md border border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400 text-sm">
{t('detail.resetPasswordDialog.success')}
</div>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
>
{t('detail.resetPasswordDialog.done')}
</button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground mb-4">
{t('detail.resetPasswordDialog.message')}
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={resetting}
>
{tc('cancel')}
</button>
<button
onClick={onConfirm}
disabled={resetting}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{resetting ? t('detail.resetPasswordDialog.sending') : t('detail.resetPasswordDialog.sendResetLink')}
</button>
</div>
</>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
export default function UserDetailPage() {
const { t } = useTranslation('users');
const { t: tc } = useTranslation('common');
const router = useRouter();
const params = useParams();
const queryClient = useQueryClient();
const id = params.id as string;
const ROLES: { value: UserDetail['role']; label: string }[] = [
{ value: 'admin', label: t('roles.admin') },
{ value: 'operator', label: t('roles.operator') },
{ value: 'viewer', label: t('roles.viewer') },
];
const STATUSES: { value: UserDetail['status']; label: string }[] = [
{ value: 'active', label: t('statuses.active') },
{ value: 'disabled', label: t('statuses.disabled') },
];
// State ----------------------------------------------------------------
const [isEditing, setIsEditing] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [resetPasswordOpen, setResetPasswordOpen] = useState(false);
const [resetPasswordSuccess, setResetPasswordSuccess] = useState(false);
const [form, setForm] = useState<EditFormData>({
displayName: '',
role: 'viewer',
status: 'active',
});
const [errors, setErrors] = useState<Partial<Record<keyof EditFormData, string>>>({});
// Queries --------------------------------------------------------------
const {
data: user,
isLoading,
error,
} = useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => apiClient<UserDetail>(`/api/v1/auth/users/${id}`),
enabled: !!id,
});
const { data: activityData } = useQuery({
queryKey: queryKeys.users.activity(id),
queryFn: () =>
apiClient<ActivityResponse>(`/api/v1/auth/users/${id}/activity`),
enabled: !!id,
});
const activityLog = activityData?.data ?? [];
// Mutations ------------------------------------------------------------
const updateMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiClient<UserDetail>(`/api/v1/auth/users/${id}`, {
method: 'PUT',
body,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
setIsEditing(false);
},
});
const deleteMutation = useMutation({
mutationFn: () =>
apiClient<void>(`/api/v1/auth/users/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
router.push('/users');
},
});
const resetPasswordMutation = useMutation({
mutationFn: () =>
apiClient<void>(`/api/v1/auth/users/${id}/reset-password`, {
method: 'POST',
}),
onSuccess: () => {
setResetPasswordSuccess(true);
},
});
// Helpers --------------------------------------------------------------
const startEditing = useCallback(() => {
if (!user) return;
setForm({
displayName: user.displayName,
role: user.role,
status: user.status,
});
setErrors({});
setIsEditing(true);
}, [user]);
const cancelEditing = useCallback(() => {
setIsEditing(false);
setErrors({});
}, []);
const handleChange = useCallback(
(field: keyof EditFormData, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
},
[],
);
const validate = useCallback((): boolean => {
const next: Partial<Record<keyof EditFormData, string>> = {};
if (!form.displayName.trim()) next.displayName = t('validation.displayNameRequired');
setErrors(next);
return Object.keys(next).length === 0;
}, [form, t]);
const handleSave = useCallback(() => {
if (!validate()) return;
const body: Record<string, unknown> = {
displayName: form.displayName.trim(),
role: form.role,
status: form.status,
};
updateMutation.mutate(body);
}, [form, validate, updateMutation]);
const handleResetPasswordClose = useCallback(() => {
setResetPasswordOpen(false);
setResetPasswordSuccess(false);
}, []);
// Render ---------------------------------------------------------------
// Loading state
if (isLoading) {
return (
<div>
<button
onClick={() => router.push('/users')}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
{t('detail.backToUsers')}
</button>
<div className="text-sm text-muted-foreground py-12 text-center">
{t('detail.loading')}
</div>
</div>
);
}
// Error state
if (error) {
return (
<div>
<button
onClick={() => router.push('/users')}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
{t('detail.backToUsers')}
</button>
<div className="p-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
{t('detail.loadError')} {(error as Error).message}
</div>
</div>
);
}
if (!user) return null;
return (
<div>
{/* Back link */}
<button
onClick={() => router.push('/users')}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-4"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5" />
<path d="m12 19-7-7 7-7" />
</svg>
{t('detail.backToUsers')}
</button>
{/* Page header */}
<div className="flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{user.displayName}</h1>
<StatusBadge status={user.status} />
<RoleBadge role={user.role} />
</div>
{/* Two-column layout */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Left column */}
<div className="lg:col-span-2 space-y-6">
{/* User Information Card */}
<div className="bg-card border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{t('detail.userInformation')}</h2>
{!isEditing && (
<button
onClick={startEditing}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
>
{tc('edit')}
</button>
)}
</div>
{isEditing ? (
/* ---------- Edit form ---------- */
<div className="space-y-4">
{/* displayName */}
<div>
<label className="block text-sm font-medium mb-1">
{t('form.displayName')} <span className="text-destructive">*</span>
</label>
<input
type="text"
value={form.displayName}
onChange={(e) => handleChange('displayName', e.target.value)}
className={cn(
'w-full px-3 py-2 rounded-md border bg-background text-sm',
errors.displayName ? 'border-destructive' : 'border-input',
)}
placeholder="John Doe"
/>
{errors.displayName && (
<p className="text-xs text-destructive mt-1">{errors.displayName}</p>
)}
</div>
{/* role */}
<div>
<label className="block text-sm font-medium mb-1">{t('form.role')}</label>
<select
value={form.role}
onChange={(e) => handleChange('role', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
{/* status */}
<div>
<label className="block text-sm font-medium mb-1">{tc('status')}</label>
<select
value={form.status}
onChange={(e) => handleChange('status', e.target.value)}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
{/* Update error */}
{updateMutation.isError && (
<div className="p-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
{t('detail.failedToUpdate')} {(updateMutation.error as Error).message}
</div>
)}
{/* Save / Cancel */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={cancelEditing}
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
disabled={updateMutation.isPending}
>
{tc('cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={updateMutation.isPending}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{updateMutation.isPending ? tc('saving') : tc('save')}
</button>
</div>
</div>
) : (
/* ---------- Read-only info display ---------- */
<dl className="divide-y">
<InfoRow label={t('form.displayName')} value={user.displayName} />
<InfoRow label={t('form.email')} value={user.email} />
<InfoRow label={t('form.role')} value={<RoleBadge role={user.role} />} />
<InfoRow label={tc('status')} value={<StatusBadge status={user.status} />} />
<InfoRow label={t('table.tenant')} value={user.tenantName || '--'} />
<InfoRow label={t('table.lastLogin')} value={formatDateTime(user.lastLoginAt)} />
<InfoRow label={tc('created')} value={formatDateTime(user.createdAt)} />
<InfoRow label={tc('updated')} value={formatDateTime(user.updatedAt)} />
</dl>
)}
</div>
{/* Activity Log */}
<div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('detail.activityLog')}</h2>
{activityLog.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
{t('detail.noActivity')}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.action')}</th>
<th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.resource')}</th>
<th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.details')}</th>
<th className="text-left px-3 py-2 font-medium">{t('detail.activityTable.ipAddress')}</th>
<th className="text-right px-3 py-2 font-medium">{t('detail.activityTable.time')}</th>
</tr>
</thead>
<tbody>
{activityLog.map((entry) => (
<tr
key={entry.id}
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
>
<td className="px-3 py-2">
<ActionBadge action={entry.action} />
</td>
<td className="px-3 py-2 text-muted-foreground">
{entry.resource || '--'}
</td>
<td className="px-3 py-2 text-muted-foreground truncate max-w-[250px]">
{entry.details || '--'}
</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{entry.ipAddress || '--'}
</td>
<td className="px-3 py-2 text-right text-muted-foreground text-xs">
{formatRelativeTime(entry.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Right column */}
<div className="space-y-6">
{/* Quick Actions */}
<div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('detail.quickActions')}</h2>
<div className="space-y-2">
<button
onClick={startEditing}
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
{t('detail.editUser')}
</button>
<button
onClick={() => {
setResetPasswordSuccess(false);
setResetPasswordOpen(true);
}}
className="w-full px-4 py-2 text-sm rounded-md border hover:bg-accent transition-colors text-left flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
{t('detail.resetPassword')}
</button>
<button
onClick={() => setDeleteOpen(true)}
className="w-full px-4 py-2 text-sm rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors text-left flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
{t('detail.deleteUser')}
</button>
</div>
</div>
{/* Account Summary */}
<div className="bg-card border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">{t('detail.accountSummary')}</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-muted-foreground mb-1">{tc('status')}</p>
<StatusBadge status={user.status} />
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">{tc('role')}</p>
<RoleBadge role={user.role} />
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">{t('detail.lastLogin')}</p>
<p className="text-sm font-medium">
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : tc('never')}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">{t('detail.memberSince')}</p>
<p className="text-sm font-medium">{formatDate(user.createdAt)}</p>
</div>
{user.tenantName && (
<div>
<p className="text-sm text-muted-foreground mb-1">{t('table.tenant')}</p>
<p className="text-sm font-medium">{user.tenantName}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Delete confirmation dialog */}
<DeleteDialog
open={deleteOpen}
userName={user.displayName}
deleting={deleteMutation.isPending}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate()}
/>
{/* Reset password dialog */}
<ResetPasswordDialog
open={resetPasswordOpen}
userName={user.displayName}
resetting={resetPasswordMutation.isPending}
success={resetPasswordSuccess}
onClose={handleResetPasswordClose}
onConfirm={() => resetPasswordMutation.mutate()}
/>
</div>
);
}