781 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|