@hed-hog/core 0.0.185 → 0.0.190
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hedhog/frontend/app/account/2fa/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/accounts/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +356 -0
- package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +379 -0
- package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +184 -0
- package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +144 -0
- package/hedhog/frontend/app/account/components/email-request-dialog.tsx.ejs +96 -0
- package/hedhog/frontend/app/account/components/mfa-add-buttons.tsx.ejs +43 -0
- package/hedhog/frontend/app/account/components/mfa-method-card.tsx.ejs +115 -0
- package/hedhog/frontend/app/account/components/mfa-setup-dialog.tsx.ejs +236 -0
- package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +209 -0
- package/hedhog/frontend/app/account/components/recovery-codes-dialog.tsx.ejs +192 -0
- package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +372 -0
- package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +337 -0
- package/hedhog/frontend/app/account/components/two-factor-auth.tsx.ejs +393 -0
- package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +332 -0
- package/hedhog/frontend/app/account/email/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/hooks/use-mfa-methods.ts.ejs +27 -0
- package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +461 -0
- package/hedhog/frontend/app/account/layout.tsx.ejs +105 -0
- package/hedhog/frontend/app/account/lib/mfa-utils.tsx.ejs +37 -0
- package/hedhog/frontend/app/account/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/password/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/profile/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/sessions/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +490 -0
- package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +62 -0
- package/hedhog/frontend/app/configurations/layout.tsx.ejs +316 -0
- package/hedhog/frontend/app/configurations/page.tsx.ejs +35 -0
- package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +351 -0
- package/hedhog/frontend/app/dashboard/[slug]/page.tsx.ejs +11 -0
- package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +62 -0
- package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +45 -0
- package/hedhog/frontend/app/dashboard/dashboard.css.ejs +196 -0
- package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +63 -0
- package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +516 -0
- package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +753 -0
- package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +516 -0
- package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +489 -0
- package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +621 -0
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -0
- package/hedhog/frontend/app/mail/log/page.tsx.ejs +312 -0
- package/hedhog/frontend/app/mail/template/page.tsx.ejs +1177 -0
- package/hedhog/frontend/app/preferences/page.tsx.ejs +448 -0
- package/hedhog/frontend/app/roles/menus.tsx.ejs +504 -0
- package/hedhog/frontend/app/roles/page.tsx.ejs +814 -0
- package/hedhog/frontend/app/roles/routes.tsx.ejs +397 -0
- package/hedhog/frontend/app/roles/users.tsx.ejs +306 -0
- package/hedhog/frontend/app/users/active-session.tsx.ejs +159 -0
- package/hedhog/frontend/app/users/identifiers.tsx.ejs +279 -0
- package/hedhog/frontend/app/users/page.tsx.ejs +1257 -0
- package/hedhog/frontend/app/users/permissions.tsx.ejs +155 -0
- package/hedhog/frontend/messages/en.json +1080 -0
- package/hedhog/frontend/messages/pt.json +1135 -0
- package/package.json +4 -4
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PageHeader,
|
|
5
|
+
PaginationFooter,
|
|
6
|
+
SearchBar,
|
|
7
|
+
StatsCards,
|
|
8
|
+
} from '@/components/entity-list';
|
|
9
|
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
10
|
+
import { Button } from '@/components/ui/button';
|
|
11
|
+
import {
|
|
12
|
+
Card,
|
|
13
|
+
CardContent,
|
|
14
|
+
CardDescription,
|
|
15
|
+
CardHeader,
|
|
16
|
+
CardTitle,
|
|
17
|
+
} from '@/components/ui/card';
|
|
18
|
+
import {
|
|
19
|
+
Dialog,
|
|
20
|
+
DialogContent,
|
|
21
|
+
DialogDescription,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
} from '@/components/ui/dialog';
|
|
25
|
+
import {
|
|
26
|
+
Form,
|
|
27
|
+
FormControl,
|
|
28
|
+
FormField,
|
|
29
|
+
FormItem,
|
|
30
|
+
FormLabel,
|
|
31
|
+
FormMessage,
|
|
32
|
+
} from '@/components/ui/form';
|
|
33
|
+
import { Input } from '@/components/ui/input';
|
|
34
|
+
import {
|
|
35
|
+
Sheet,
|
|
36
|
+
SheetContent,
|
|
37
|
+
SheetDescription,
|
|
38
|
+
SheetHeader,
|
|
39
|
+
SheetTitle,
|
|
40
|
+
} from '@/components/ui/sheet';
|
|
41
|
+
import { formatDateTime } from '@/lib/format-date';
|
|
42
|
+
import { getPhotoUrl } from '@/lib/get-photo-url';
|
|
43
|
+
import { getUserEmail } from '@/lib/get-user-email';
|
|
44
|
+
import { PaginatedResult } from '@hed-hog/api-pagination';
|
|
45
|
+
import { User, UserMfa } from '@hed-hog/api-types';
|
|
46
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
47
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
48
|
+
import { Avatar, AvatarFallback, AvatarImage } from '@radix-ui/react-avatar';
|
|
49
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs';
|
|
50
|
+
import { formatDistanceToNow } from 'date-fns';
|
|
51
|
+
import { enUS, ptBR } from 'date-fns/locale';
|
|
52
|
+
import {
|
|
53
|
+
Activity,
|
|
54
|
+
Camera,
|
|
55
|
+
Clock,
|
|
56
|
+
Eye,
|
|
57
|
+
EyeOff,
|
|
58
|
+
Fingerprint,
|
|
59
|
+
Key,
|
|
60
|
+
KeyRound,
|
|
61
|
+
LogOut,
|
|
62
|
+
LucideIcon,
|
|
63
|
+
Mail,
|
|
64
|
+
Monitor,
|
|
65
|
+
RefreshCcw,
|
|
66
|
+
Save,
|
|
67
|
+
Shield,
|
|
68
|
+
ShieldCheck,
|
|
69
|
+
ShieldOff,
|
|
70
|
+
Trash2,
|
|
71
|
+
UserCircle,
|
|
72
|
+
UserIcon,
|
|
73
|
+
UserPlus,
|
|
74
|
+
Users,
|
|
75
|
+
} from 'lucide-react';
|
|
76
|
+
import { useTranslations } from 'next-intl';
|
|
77
|
+
import { useEffect, useState } from 'react';
|
|
78
|
+
import { useForm } from 'react-hook-form';
|
|
79
|
+
import { toast } from 'sonner';
|
|
80
|
+
import { z } from 'zod';
|
|
81
|
+
import { ActiveSessions } from './active-session';
|
|
82
|
+
import { UserIdentifiersSection } from './identifiers';
|
|
83
|
+
import { PermissionsSection } from './permissions';
|
|
84
|
+
|
|
85
|
+
type RequestResponse<T> = {
|
|
86
|
+
paginate: PaginatedResult<T>;
|
|
87
|
+
stats: {
|
|
88
|
+
total: number;
|
|
89
|
+
newLast7Days: number;
|
|
90
|
+
blocked: number;
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default function UserPage() {
|
|
95
|
+
const t = useTranslations('core.UserPage');
|
|
96
|
+
const userActivityT = useTranslations('core.UserActivity');
|
|
97
|
+
const { request, currentLocaleCode, getSettingValue } = useApp();
|
|
98
|
+
|
|
99
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
100
|
+
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
101
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
102
|
+
const [editingUser, setEditingUser] = useState<User | null>(null);
|
|
103
|
+
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
|
104
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
105
|
+
const [editFormError, setEditFormError] = useState<string | null>(null);
|
|
106
|
+
const [photo, setPhoto] = useState<number | null | undefined>(null);
|
|
107
|
+
|
|
108
|
+
const [page, setPage] = useState(1);
|
|
109
|
+
const [pageSize, setPageSize] = useState(12);
|
|
110
|
+
const [statusFilter, setStatusFilter] = useState('all');
|
|
111
|
+
|
|
112
|
+
const {
|
|
113
|
+
data: { paginate, stats } = {
|
|
114
|
+
paginate: {},
|
|
115
|
+
stats: { total: 0, newLast7Days: 0, blocked: 0 },
|
|
116
|
+
},
|
|
117
|
+
isLoading,
|
|
118
|
+
refetch,
|
|
119
|
+
} = useQuery<RequestResponse<User>>({
|
|
120
|
+
queryKey: [
|
|
121
|
+
'users',
|
|
122
|
+
page,
|
|
123
|
+
pageSize,
|
|
124
|
+
searchQuery,
|
|
125
|
+
statusFilter,
|
|
126
|
+
currentLocaleCode,
|
|
127
|
+
],
|
|
128
|
+
queryFn: async () => {
|
|
129
|
+
const params = new URLSearchParams();
|
|
130
|
+
params.set('page', String(page));
|
|
131
|
+
params.set('pageSize', String(pageSize));
|
|
132
|
+
if (searchQuery) params.set('search', searchQuery);
|
|
133
|
+
if (statusFilter && statusFilter !== 'all')
|
|
134
|
+
params.set('filter', statusFilter);
|
|
135
|
+
|
|
136
|
+
const response = await request<RequestResponse<User>>({
|
|
137
|
+
url: `/user?${params.toString()}`,
|
|
138
|
+
method: 'GET',
|
|
139
|
+
});
|
|
140
|
+
return response.data;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const addUserSchema = z.object({
|
|
145
|
+
name: z.string().min(2, t('errorName')),
|
|
146
|
+
email: z.string().email(t('errorEmail')),
|
|
147
|
+
password: z
|
|
148
|
+
.string()
|
|
149
|
+
.min(6, t('errorPassword'))
|
|
150
|
+
.optional()
|
|
151
|
+
.or(z.literal('')),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const form = useForm<z.infer<typeof addUserSchema>>({
|
|
155
|
+
resolver: zodResolver(addUserSchema),
|
|
156
|
+
defaultValues: {
|
|
157
|
+
name: '',
|
|
158
|
+
email: '',
|
|
159
|
+
password: '',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const editUserSchema = z.object({
|
|
164
|
+
name: z.string().min(2, t('errorName')),
|
|
165
|
+
email: z.string().email(t('errorEmail')),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const editForm = useForm({
|
|
169
|
+
resolver: zodResolver(editUserSchema),
|
|
170
|
+
defaultValues: {
|
|
171
|
+
name: '',
|
|
172
|
+
email: '',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const [activeTab, setActiveTab] = useState('overview');
|
|
177
|
+
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (editingUser) {
|
|
181
|
+
editForm.reset({
|
|
182
|
+
name: editingUser.name || '',
|
|
183
|
+
email: getUserEmail(editingUser),
|
|
184
|
+
});
|
|
185
|
+
setActiveTab('overview');
|
|
186
|
+
}
|
|
187
|
+
}, [editingUser]);
|
|
188
|
+
|
|
189
|
+
const onSubmit = async (values: z.infer<typeof addUserSchema>) => {
|
|
190
|
+
try {
|
|
191
|
+
await request({
|
|
192
|
+
url: '/user',
|
|
193
|
+
method: 'POST',
|
|
194
|
+
data: values,
|
|
195
|
+
});
|
|
196
|
+
form.reset();
|
|
197
|
+
refetch();
|
|
198
|
+
setIsDialogOpen(false);
|
|
199
|
+
setFormError(null);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const e: any = err;
|
|
202
|
+
const msg =
|
|
203
|
+
e?.response?.data?.message ||
|
|
204
|
+
e?.response?.data?.error ||
|
|
205
|
+
e?.message ||
|
|
206
|
+
'Server error';
|
|
207
|
+
setFormError(String(msg));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handleEdit = (user: User) => {
|
|
212
|
+
setEditFormError(null);
|
|
213
|
+
setEditingUser(user);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const onDelete = async () => {
|
|
217
|
+
try {
|
|
218
|
+
await request({
|
|
219
|
+
url: `/user`,
|
|
220
|
+
method: 'DELETE',
|
|
221
|
+
data: {
|
|
222
|
+
ids: [Number(editingUser?.id)],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
refetch();
|
|
226
|
+
setOpenDeleteModal(false);
|
|
227
|
+
setEditingUser(null);
|
|
228
|
+
setEditFormError(null);
|
|
229
|
+
toast.success(t('userDeletedSuccess'));
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const e: any = err;
|
|
232
|
+
const msg =
|
|
233
|
+
e?.response?.data?.message ||
|
|
234
|
+
e?.response?.data?.error ||
|
|
235
|
+
e?.message ||
|
|
236
|
+
t('serverError');
|
|
237
|
+
setEditFormError(String(msg));
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const handleRefreshEditingUser = async () => {
|
|
242
|
+
const updated = await refetch();
|
|
243
|
+
const freshUser = updated.data?.paginate?.data.find(
|
|
244
|
+
(u: User) => u.id === editingUser?.id
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (freshUser) {
|
|
248
|
+
setEditingUser(freshUser);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const onEditSubmit = async (values: z.infer<typeof editUserSchema>) => {
|
|
253
|
+
try {
|
|
254
|
+
await request({
|
|
255
|
+
url: `/user/${editingUser?.id}`,
|
|
256
|
+
method: 'PATCH',
|
|
257
|
+
data: values,
|
|
258
|
+
});
|
|
259
|
+
refetch();
|
|
260
|
+
setEditFormError(null);
|
|
261
|
+
toast.success(t('userUpdatedSuccess'));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
const e: any = err;
|
|
264
|
+
const msg =
|
|
265
|
+
e?.response?.data?.message ||
|
|
266
|
+
e?.response?.data?.error ||
|
|
267
|
+
e?.message ||
|
|
268
|
+
t('serverError');
|
|
269
|
+
setEditFormError(String(msg));
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const handleAvatarClick = () => {
|
|
274
|
+
const input = document.createElement('input');
|
|
275
|
+
input.type = 'file';
|
|
276
|
+
input.accept = 'image/*';
|
|
277
|
+
input.onchange = async (e: any) => {
|
|
278
|
+
const file = e.target?.files?.[0];
|
|
279
|
+
if (file && editingUser) {
|
|
280
|
+
setIsUploadingAvatar(true);
|
|
281
|
+
try {
|
|
282
|
+
const formData = new FormData();
|
|
283
|
+
formData.append('avatar', file);
|
|
284
|
+
|
|
285
|
+
const response: any = await request({
|
|
286
|
+
url: `/user/${editingUser.id}/avatar`,
|
|
287
|
+
method: 'POST',
|
|
288
|
+
data: formData,
|
|
289
|
+
headers: {
|
|
290
|
+
'Content-Type': 'multipart/form-data',
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
setPhoto(response.data.id);
|
|
295
|
+
toast.success(t('pictureUpdatedSuccess'));
|
|
296
|
+
refetch();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error(err);
|
|
299
|
+
} finally {
|
|
300
|
+
setIsUploadingAvatar(false);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
input.click();
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const accountProviders = [
|
|
308
|
+
{ name: 'Google', icon: '/google.svg' },
|
|
309
|
+
{ name: 'GitHub', icon: '/github.svg' },
|
|
310
|
+
{ name: 'Facebook', icon: '/facebook.svg' },
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div className="flex flex-col h-screen px-4">
|
|
315
|
+
<PageHeader
|
|
316
|
+
breadcrumbs={[{ label: 'Home', href: '/' }, { label: t('users') }]}
|
|
317
|
+
actions={[
|
|
318
|
+
{
|
|
319
|
+
label: t('buttonAddUser'),
|
|
320
|
+
onClick: () => setIsDialogOpen(true),
|
|
321
|
+
variant: 'default',
|
|
322
|
+
},
|
|
323
|
+
]}
|
|
324
|
+
title={t('title')}
|
|
325
|
+
description={t('description')}
|
|
326
|
+
/>
|
|
327
|
+
|
|
328
|
+
<StatsCards
|
|
329
|
+
stats={[
|
|
330
|
+
{
|
|
331
|
+
title: t('totalUsers'),
|
|
332
|
+
value: String(stats?.total || 0),
|
|
333
|
+
icon: <Users className="h-5 w-5" />,
|
|
334
|
+
iconBgColor: 'bg-purple-50',
|
|
335
|
+
iconColor: 'text-purple-600',
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
title: t('totalNewUsers7Days'),
|
|
339
|
+
value: String(stats?.newLast7Days || 0),
|
|
340
|
+
icon: <UserPlus className="h-5 w-5" />,
|
|
341
|
+
iconBgColor: 'bg-rose-50',
|
|
342
|
+
iconColor: 'text-rose-600',
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
title: t('totalValidatedUsers'),
|
|
346
|
+
value: '0',
|
|
347
|
+
icon: <Mail className="h-5 w-5" />,
|
|
348
|
+
iconBgColor: 'bg-green-50',
|
|
349
|
+
iconColor: 'text-green-600',
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
title: t('totalBlockedUsers'),
|
|
353
|
+
value: String(stats?.blocked || 0),
|
|
354
|
+
icon: <ShieldCheck className="h-5 w-5" />,
|
|
355
|
+
iconBgColor: 'bg-amber-50',
|
|
356
|
+
iconColor: 'text-amber-600',
|
|
357
|
+
},
|
|
358
|
+
]}
|
|
359
|
+
/>
|
|
360
|
+
|
|
361
|
+
<SearchBar
|
|
362
|
+
searchQuery={searchQuery}
|
|
363
|
+
onSearchChange={setSearchQuery}
|
|
364
|
+
onSearch={() => refetch()}
|
|
365
|
+
placeholder={t('searchPlaceholder')}
|
|
366
|
+
filters={{
|
|
367
|
+
value: statusFilter,
|
|
368
|
+
onChange: (value) => {
|
|
369
|
+
setStatusFilter(value);
|
|
370
|
+
setPage(1);
|
|
371
|
+
},
|
|
372
|
+
placeholder: t('filterPlaceholder'),
|
|
373
|
+
options: [
|
|
374
|
+
{ label: t('filterOptionAll'), value: 'all' },
|
|
375
|
+
{ label: t('filterOptionNew'), value: 'new' },
|
|
376
|
+
{ label: t('filterOptionBlocked'), value: 'blocked' },
|
|
377
|
+
],
|
|
378
|
+
}}
|
|
379
|
+
className="mt-4"
|
|
380
|
+
/>
|
|
381
|
+
|
|
382
|
+
<div className="flex-1 pt-4">
|
|
383
|
+
{isLoading && (
|
|
384
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
385
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
386
|
+
<Card
|
|
387
|
+
key={`skeleton-${i}`}
|
|
388
|
+
className="flex flex-col justify-between gap-2 rounded-2xl border border-border/60 bg-card p-4 shadow-sm animate-pulse"
|
|
389
|
+
>
|
|
390
|
+
<CardHeader className="flex items-start justify-between gap-4 p-0">
|
|
391
|
+
<div className="flex items-center gap-3">
|
|
392
|
+
<div className="h-12 w-12 shrink-0 rounded-full bg-muted" />
|
|
393
|
+
<div className="space-y-2 py-1">
|
|
394
|
+
<div className="h-4 w-40 rounded bg-muted" />
|
|
395
|
+
<div className="h-3 w-32 rounded bg-muted" />
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
<div className="flex flex-col items-end gap-2">
|
|
399
|
+
<div className="h-8 w-20 rounded bg-muted" />
|
|
400
|
+
<div className="mt-1 h-5 w-20 rounded bg-muted" />
|
|
401
|
+
</div>
|
|
402
|
+
</CardHeader>
|
|
403
|
+
<div className="mt-3 space-y-2 text-sm">
|
|
404
|
+
<div className="h-3 w-full rounded bg-muted" />
|
|
405
|
+
<div className="h-3 w-full rounded bg-muted" />
|
|
406
|
+
<div className="h-3 w-1/2 rounded bg-muted" />
|
|
407
|
+
</div>
|
|
408
|
+
</Card>
|
|
409
|
+
))}
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
|
|
413
|
+
{(paginate?.data?.length || 0) === 0 ? (
|
|
414
|
+
<p className="text-sm text-muted-foreground">{t('noUsersFound')}</p>
|
|
415
|
+
) : (
|
|
416
|
+
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
|
417
|
+
{paginate?.data?.map((user: User) => (
|
|
418
|
+
<Card
|
|
419
|
+
key={user.id}
|
|
420
|
+
onDoubleClick={() => handleEdit(user)}
|
|
421
|
+
className="cursor-pointer rounded-md flex flex-col justify-between gap-2 border border-border/60 bg-card p-4 shadow-sm transition hover:border-primary"
|
|
422
|
+
>
|
|
423
|
+
<CardHeader className="flex items-start justify-between gap-4 p-0">
|
|
424
|
+
<div className="flex items-center gap-3">
|
|
425
|
+
<div className="h-12 w-12 shrink-0 rounded-full bg-muted">
|
|
426
|
+
<img
|
|
427
|
+
src={getPhotoUrl(user.photo_id)}
|
|
428
|
+
alt={user.name}
|
|
429
|
+
className="h-12 w-12 rounded-full object-cover"
|
|
430
|
+
/>
|
|
431
|
+
</div>
|
|
432
|
+
<div>
|
|
433
|
+
<CardTitle className="text-sm font-semibold">
|
|
434
|
+
{user.name || '—'}
|
|
435
|
+
</CardTitle>
|
|
436
|
+
<CardDescription className="text-xs text-muted-foreground">
|
|
437
|
+
{getUserEmail(user)}
|
|
438
|
+
</CardDescription>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
<div className="flex flex-col items-end gap-2">
|
|
442
|
+
<Button
|
|
443
|
+
variant="outline"
|
|
444
|
+
size="sm"
|
|
445
|
+
onClick={() => handleEdit(user)}
|
|
446
|
+
>
|
|
447
|
+
{t('buttonEditUser')}
|
|
448
|
+
</Button>
|
|
449
|
+
|
|
450
|
+
<div className="mt-1">
|
|
451
|
+
{user.suspended_until ? (
|
|
452
|
+
<span className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700">
|
|
453
|
+
<ShieldCheck className="h-3 w-3" />
|
|
454
|
+
{t('blocked')}
|
|
455
|
+
</span>
|
|
456
|
+
) : (
|
|
457
|
+
<span className="inline-flex items-center gap-2 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
|
|
458
|
+
<Users className="h-3 w-3" />
|
|
459
|
+
{t('active')}
|
|
460
|
+
</span>
|
|
461
|
+
)}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</CardHeader>
|
|
465
|
+
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
|
466
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
467
|
+
{(user as any).user_account?.includes('google') && (
|
|
468
|
+
<div
|
|
469
|
+
title={t('googleConnected')}
|
|
470
|
+
className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
|
|
471
|
+
>
|
|
472
|
+
<svg
|
|
473
|
+
className="h-4 w-4"
|
|
474
|
+
viewBox="0 0 24 24"
|
|
475
|
+
fill="currentColor"
|
|
476
|
+
aria-hidden
|
|
477
|
+
/>
|
|
478
|
+
{t('google')}
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
{(user as any).connectedAccounts?.includes('github') && (
|
|
482
|
+
<div
|
|
483
|
+
title={t('githubConnected')}
|
|
484
|
+
className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
|
|
485
|
+
>
|
|
486
|
+
<svg
|
|
487
|
+
className="h-4 w-4"
|
|
488
|
+
viewBox="0 0 24 24"
|
|
489
|
+
fill="currentColor"
|
|
490
|
+
aria-hidden
|
|
491
|
+
/>
|
|
492
|
+
{t('github')}
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
{(user as any).connectedAccounts?.includes('facebook') && (
|
|
496
|
+
<div
|
|
497
|
+
title={t('facebookConnected')}
|
|
498
|
+
className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
|
|
499
|
+
>
|
|
500
|
+
<svg
|
|
501
|
+
className="h-4 w-4"
|
|
502
|
+
viewBox="0 0 24 24"
|
|
503
|
+
fill="currentColor"
|
|
504
|
+
aria-hidden
|
|
505
|
+
/>
|
|
506
|
+
{t('facebook')}
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
{(!(user as any).connectedAccounts ||
|
|
510
|
+
(user as any).connectedAccounts.length === 0) && (
|
|
511
|
+
<div className="text-xs text-muted-foreground">
|
|
512
|
+
{t('noConnectedAccounts')}
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
517
|
+
{user.user_mfa && user.user_mfa.length > 0 ? (
|
|
518
|
+
user.user_mfa.map((mfa: UserMfa) => (
|
|
519
|
+
<span
|
|
520
|
+
key={String(mfa.id)}
|
|
521
|
+
className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
|
|
522
|
+
title={`MFA: ${mfa}`}
|
|
523
|
+
>
|
|
524
|
+
{mfa.name}
|
|
525
|
+
</span>
|
|
526
|
+
))
|
|
527
|
+
) : (
|
|
528
|
+
<span className="text-xs text-muted-foreground">
|
|
529
|
+
{t('noMfaMethods')}
|
|
530
|
+
</span>
|
|
531
|
+
)}
|
|
532
|
+
</div>
|
|
533
|
+
{user.suspended_reason && (
|
|
534
|
+
<div className="text-xs text-muted-foreground">
|
|
535
|
+
<span className="font-medium text-foreground">
|
|
536
|
+
{t('reason')}:
|
|
537
|
+
</span>{' '}
|
|
538
|
+
{user.suspended_reason}
|
|
539
|
+
</div>
|
|
540
|
+
)}
|
|
541
|
+
{user.suspended_until && (
|
|
542
|
+
<div className="text-xs text-muted-foreground">
|
|
543
|
+
<span className="font-medium text-foreground">
|
|
544
|
+
{t('until')}:
|
|
545
|
+
</span>{' '}
|
|
546
|
+
{user.suspended_until}
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
</Card>
|
|
551
|
+
))}
|
|
552
|
+
</div>
|
|
553
|
+
)}
|
|
554
|
+
|
|
555
|
+
<div className="w-full border-t pt-2 mt-4">
|
|
556
|
+
<PaginationFooter
|
|
557
|
+
currentPage={page}
|
|
558
|
+
pageSize={pageSize}
|
|
559
|
+
totalItems={
|
|
560
|
+
(paginate as any)?.total ?? (paginate as any)?.count ?? 0
|
|
561
|
+
}
|
|
562
|
+
onPageChange={setPage}
|
|
563
|
+
onPageSizeChange={(size) => {
|
|
564
|
+
setPageSize(size);
|
|
565
|
+
setPage(1);
|
|
566
|
+
}}
|
|
567
|
+
pageSizeOptions={[6, 12, 24, 48]}
|
|
568
|
+
/>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
572
|
+
<DialogContent className="sm:max-w-lg">
|
|
573
|
+
<DialogHeader>
|
|
574
|
+
<DialogTitle>{t('dialogAddUserTitle')}</DialogTitle>
|
|
575
|
+
<DialogDescription>{t('description')}</DialogDescription>
|
|
576
|
+
</DialogHeader>
|
|
577
|
+
<div className="w-full border-t pt-1 mt-1" />
|
|
578
|
+
<Form {...form}>
|
|
579
|
+
<form
|
|
580
|
+
onSubmit={form.handleSubmit(onSubmit)}
|
|
581
|
+
className="space-y-4"
|
|
582
|
+
>
|
|
583
|
+
<FormField
|
|
584
|
+
control={form.control}
|
|
585
|
+
name="name"
|
|
586
|
+
render={({ field }) => (
|
|
587
|
+
<FormItem>
|
|
588
|
+
<FormLabel>{t('formNameLabel')}</FormLabel>
|
|
589
|
+
<FormControl>
|
|
590
|
+
<Input {...field} />
|
|
591
|
+
</FormControl>
|
|
592
|
+
<FormMessage />
|
|
593
|
+
</FormItem>
|
|
594
|
+
)}
|
|
595
|
+
/>
|
|
596
|
+
<FormField
|
|
597
|
+
control={form.control}
|
|
598
|
+
name="email"
|
|
599
|
+
render={({ field }) => (
|
|
600
|
+
<FormItem>
|
|
601
|
+
<FormLabel>{t('formEmailLabel')}</FormLabel>
|
|
602
|
+
<FormControl>
|
|
603
|
+
<Input type="email" {...field} />
|
|
604
|
+
</FormControl>
|
|
605
|
+
<FormMessage />
|
|
606
|
+
</FormItem>
|
|
607
|
+
)}
|
|
608
|
+
/>
|
|
609
|
+
<FormField
|
|
610
|
+
control={form.control}
|
|
611
|
+
name="password"
|
|
612
|
+
render={({ field }) => (
|
|
613
|
+
<FormItem>
|
|
614
|
+
<FormLabel>{t('formPasswordLabel')}</FormLabel>
|
|
615
|
+
<FormControl>
|
|
616
|
+
<div className="relative">
|
|
617
|
+
<Input
|
|
618
|
+
type={showPassword ? 'text' : 'password'}
|
|
619
|
+
{...field}
|
|
620
|
+
className="pr-10"
|
|
621
|
+
/>
|
|
622
|
+
<button
|
|
623
|
+
type="button"
|
|
624
|
+
onClick={() => setShowPassword((s) => !s)}
|
|
625
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded p-1 text-sm text-muted-foreground hover:bg-muted/50"
|
|
626
|
+
aria-label={
|
|
627
|
+
showPassword
|
|
628
|
+
? t('hidePassword')
|
|
629
|
+
: t('showPassword')
|
|
630
|
+
}
|
|
631
|
+
>
|
|
632
|
+
{showPassword ? (
|
|
633
|
+
<EyeOff className="h-4 w-4" />
|
|
634
|
+
) : (
|
|
635
|
+
<Eye className="h-4 w-4" />
|
|
636
|
+
)}
|
|
637
|
+
</button>
|
|
638
|
+
</div>
|
|
639
|
+
</FormControl>
|
|
640
|
+
<FormMessage />
|
|
641
|
+
</FormItem>
|
|
642
|
+
)}
|
|
643
|
+
/>
|
|
644
|
+
|
|
645
|
+
{formError && (
|
|
646
|
+
<Alert
|
|
647
|
+
variant="destructive"
|
|
648
|
+
className="border-red-300 bg-red-50 rounded-md p-4"
|
|
649
|
+
>
|
|
650
|
+
<AlertTitle className="text-sm">
|
|
651
|
+
{t('verifyYourInput')}
|
|
652
|
+
</AlertTitle>
|
|
653
|
+
<AlertDescription className="text-sm">
|
|
654
|
+
{formError}
|
|
655
|
+
</AlertDescription>
|
|
656
|
+
</Alert>
|
|
657
|
+
)}
|
|
658
|
+
|
|
659
|
+
<Button type="submit" className="w-full">
|
|
660
|
+
{t('buttonAddUser')}
|
|
661
|
+
</Button>
|
|
662
|
+
</form>
|
|
663
|
+
</Form>
|
|
664
|
+
</DialogContent>
|
|
665
|
+
</Dialog>
|
|
666
|
+
|
|
667
|
+
{editingUser && (
|
|
668
|
+
<Sheet
|
|
669
|
+
open={!!editingUser}
|
|
670
|
+
onOpenChange={() => {
|
|
671
|
+
setEditingUser(null);
|
|
672
|
+
setPhoto(null);
|
|
673
|
+
}}
|
|
674
|
+
>
|
|
675
|
+
<SheetContent className="w-full sm:max-w-4xl overflow-y-auto gap-0">
|
|
676
|
+
<SheetHeader>
|
|
677
|
+
<SheetTitle>{t('titleEditUser')}</SheetTitle>
|
|
678
|
+
<SheetDescription>{t('description')}</SheetDescription>
|
|
679
|
+
</SheetHeader>
|
|
680
|
+
|
|
681
|
+
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
682
|
+
<TabsList className="grid w-full grid-cols-7 bg-muted rounded-md text-muted-foreground text-sm">
|
|
683
|
+
<TabsTrigger
|
|
684
|
+
value="overview"
|
|
685
|
+
className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
|
|
686
|
+
>
|
|
687
|
+
<UserCircle className="h-4 w-4 min-h-4 min-w-4" />
|
|
688
|
+
<span className="hidden md:inline">{t('tabOverview')}</span>
|
|
689
|
+
</TabsTrigger>
|
|
690
|
+
<TabsTrigger
|
|
691
|
+
value="edit"
|
|
692
|
+
className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
|
|
693
|
+
>
|
|
694
|
+
<Save className="h-4 w-4 min-h-4 min-w-4" />
|
|
695
|
+
<span className="hidden md:inline">{t('tabEdit')}</span>
|
|
696
|
+
</TabsTrigger>
|
|
697
|
+
<TabsTrigger
|
|
698
|
+
value="credentials"
|
|
699
|
+
className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
|
|
700
|
+
>
|
|
701
|
+
<Key className="h-4 w-4 min-h-4 min-w-4" />
|
|
702
|
+
<span className="hidden md:inline">
|
|
703
|
+
{t('tabCredentials')}
|
|
704
|
+
</span>
|
|
705
|
+
</TabsTrigger>
|
|
706
|
+
<TabsTrigger
|
|
707
|
+
value="identifiers"
|
|
708
|
+
className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
|
|
709
|
+
>
|
|
710
|
+
<Fingerprint className="h-4 w-4 min-h-4 min-w-4" />
|
|
711
|
+
<span className="hidden md:inline">
|
|
712
|
+
{t('tabIdentifiers')}
|
|
713
|
+
</span>
|
|
714
|
+
</TabsTrigger>
|
|
715
|
+
<TabsTrigger
|
|
716
|
+
value="permissions"
|
|
717
|
+
className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
|
|
718
|
+
>
|
|
719
|
+
<ShieldCheck className="h-4 w-4 min-h-4 min-w-4" />
|
|
720
|
+
<span className="hidden md:inline">
|
|
721
|
+
{t('tabPermissions')}
|
|
722
|
+
</span>
|
|
723
|
+
</TabsTrigger>
|
|
724
|
+
<TabsTrigger
|
|
725
|
+
value="mfa"
|
|
726
|
+
className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
|
|
727
|
+
>
|
|
728
|
+
<Shield className="h-4 w-4 min-h-4 min-w-4" />
|
|
729
|
+
<span className="hidden md:inline">{t('tabMfa')}</span>
|
|
730
|
+
</TabsTrigger>
|
|
731
|
+
<TabsTrigger
|
|
732
|
+
value="sessions"
|
|
733
|
+
className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
|
|
734
|
+
>
|
|
735
|
+
<Monitor className="h-4 w-4 min-h-4 min-w-4" />
|
|
736
|
+
<span className="hidden md:inline">{t('tabSessions')}</span>
|
|
737
|
+
</TabsTrigger>
|
|
738
|
+
</TabsList>
|
|
739
|
+
|
|
740
|
+
<TabsContent
|
|
741
|
+
value="overview"
|
|
742
|
+
className="space-y-4 mt-4 p-4 pt-0"
|
|
743
|
+
>
|
|
744
|
+
<div className="flex flex-col items-center gap-3 py-4">
|
|
745
|
+
<div className="relative group">
|
|
746
|
+
<Avatar className="h-24 w-24 shadow-md">
|
|
747
|
+
<AvatarImage
|
|
748
|
+
src={getPhotoUrl(photo || editingUser.photo_id)}
|
|
749
|
+
alt={editingUser.name}
|
|
750
|
+
className="object-cover h-24 w-24 rounded-full"
|
|
751
|
+
/>
|
|
752
|
+
<AvatarFallback className="text-2xl font-semibold bg-linear-to-br from-purple-500 to-pink-500 text-white">
|
|
753
|
+
{editingUser.name?.charAt(0)?.toUpperCase() || 'U'}
|
|
754
|
+
</AvatarFallback>
|
|
755
|
+
</Avatar>
|
|
756
|
+
<button
|
|
757
|
+
onClick={handleAvatarClick}
|
|
758
|
+
disabled={isUploadingAvatar}
|
|
759
|
+
className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed"
|
|
760
|
+
>
|
|
761
|
+
{isUploadingAvatar ? (
|
|
762
|
+
<div className="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
763
|
+
) : (
|
|
764
|
+
<Camera className="h-6 w-6 text-white" />
|
|
765
|
+
)}
|
|
766
|
+
</button>
|
|
767
|
+
<div className="absolute -bottom-1 -right-1">
|
|
768
|
+
{editingUser.suspended_until ? (
|
|
769
|
+
<div className="rounded-full bg-rose-500 p-1.5 shadow-md">
|
|
770
|
+
<ShieldCheck className="h-3.5 w-3.5 text-white" />
|
|
771
|
+
</div>
|
|
772
|
+
) : (
|
|
773
|
+
<div className="rounded-full bg-green-500 p-1.5 shadow-md">
|
|
774
|
+
<Users className="h-3.5 w-3.5 text-white" />
|
|
775
|
+
</div>
|
|
776
|
+
)}
|
|
777
|
+
</div>
|
|
778
|
+
</div>
|
|
779
|
+
<div className="text-center space-y-0.5">
|
|
780
|
+
<h3 className="text-xl font-bold bg-linear-to-r from-purple-600 to-pink-600 bg-clip-text text-transparent">
|
|
781
|
+
{editingUser.name}
|
|
782
|
+
</h3>
|
|
783
|
+
<p className="text-sm text-muted-foreground flex items-center gap-2 justify-center">
|
|
784
|
+
<Mail className="h-4 w-4" />
|
|
785
|
+
{getUserEmail(editingUser)}
|
|
786
|
+
</p>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
|
|
790
|
+
<div className="grid grid-cols-2 gap-2">
|
|
791
|
+
<Card
|
|
792
|
+
className={`border-l-4 ${editingUser.suspended_until ? 'border-l-red-500' : 'border-l-green-500'} py-2`}
|
|
793
|
+
>
|
|
794
|
+
<CardHeader className="p-2">
|
|
795
|
+
<div className="flex items-center justify-between">
|
|
796
|
+
<div>
|
|
797
|
+
<CardDescription className="text-xs">
|
|
798
|
+
{t('status')}
|
|
799
|
+
</CardDescription>
|
|
800
|
+
<CardTitle className="text-sm">
|
|
801
|
+
{editingUser.suspended_until ? (
|
|
802
|
+
<span className="text-rose-600">
|
|
803
|
+
{t('blocked')}
|
|
804
|
+
</span>
|
|
805
|
+
) : (
|
|
806
|
+
<span className="text-green-600">
|
|
807
|
+
{t('active')}
|
|
808
|
+
</span>
|
|
809
|
+
)}
|
|
810
|
+
</CardTitle>
|
|
811
|
+
</div>
|
|
812
|
+
<div
|
|
813
|
+
className={`rounded-full p-1 ${editingUser.suspended_until ? 'bg-rose-100' : 'bg-green-100'}`}
|
|
814
|
+
>
|
|
815
|
+
<Activity
|
|
816
|
+
className={`h-6 w-6 ${editingUser.suspended_until ? 'text-rose-600' : 'text-green-600'}`}
|
|
817
|
+
/>
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
</CardHeader>
|
|
821
|
+
</Card>
|
|
822
|
+
|
|
823
|
+
<Card className="border-l-4 border-l-primary-500 py-2">
|
|
824
|
+
<CardHeader className="p-2">
|
|
825
|
+
<div className="flex items-center justify-between">
|
|
826
|
+
<div>
|
|
827
|
+
<CardDescription className="text-xs">
|
|
828
|
+
{t('cardLastLoginDescription')}
|
|
829
|
+
</CardDescription>
|
|
830
|
+
{Boolean(editingUser.user_session?.length) ? (
|
|
831
|
+
<CardTitle className="text-sm truncate">
|
|
832
|
+
{editingUser.user_session?.[0]?.created_at &&
|
|
833
|
+
formatDistanceToNow(
|
|
834
|
+
new Date(
|
|
835
|
+
editingUser.user_session?.[0]?.created_at
|
|
836
|
+
),
|
|
837
|
+
{
|
|
838
|
+
addSuffix: true,
|
|
839
|
+
locale:
|
|
840
|
+
currentLocaleCode === 'en'
|
|
841
|
+
? enUS
|
|
842
|
+
: ptBR,
|
|
843
|
+
}
|
|
844
|
+
)}
|
|
845
|
+
<small className="text-xs ml-2 text-muted-foreground">
|
|
846
|
+
{formatDateTime(
|
|
847
|
+
String(
|
|
848
|
+
editingUser.user_session?.[0]?.created_at
|
|
849
|
+
),
|
|
850
|
+
getSettingValue,
|
|
851
|
+
currentLocaleCode
|
|
852
|
+
)}
|
|
853
|
+
</small>
|
|
854
|
+
</CardTitle>
|
|
855
|
+
) : (
|
|
856
|
+
<CardTitle>{t('cardLastLoginTitle')}</CardTitle>
|
|
857
|
+
)}
|
|
858
|
+
</div>
|
|
859
|
+
<div className="rounded-full bg-primary-100 p-1">
|
|
860
|
+
<Clock className="h-6 w-6 text-primary-600" />
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
</CardHeader>
|
|
864
|
+
</Card>
|
|
865
|
+
</div>
|
|
866
|
+
|
|
867
|
+
<div className="space-y-3">
|
|
868
|
+
<div className="flex items-center justify-between">
|
|
869
|
+
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
870
|
+
<Shield className="h-4 w-4" />
|
|
871
|
+
{t('securityTitle')}
|
|
872
|
+
</h4>
|
|
873
|
+
</div>
|
|
874
|
+
|
|
875
|
+
<div className="grid grid-cols-3 gap-2">
|
|
876
|
+
<Card className="border-l-4 border-l-amber-500 py-2">
|
|
877
|
+
<CardHeader className="p-2">
|
|
878
|
+
<div className="flex flex-col items-center text-center">
|
|
879
|
+
<div className="rounded-full bg-amber-100 p-1 mb-1">
|
|
880
|
+
<Shield className="h-4 w-4 text-amber-600" />
|
|
881
|
+
</div>
|
|
882
|
+
<CardDescription className="text-xs">
|
|
883
|
+
{t('securityDescription')}
|
|
884
|
+
</CardDescription>
|
|
885
|
+
<CardTitle className="text-sm font-bold">
|
|
886
|
+
{editingUser.user_mfa &&
|
|
887
|
+
editingUser.user_mfa.length > 0 ? (
|
|
888
|
+
<span className="text-amber-600">
|
|
889
|
+
{t('securityLevelHight')}
|
|
890
|
+
</span>
|
|
891
|
+
) : (
|
|
892
|
+
<span className="text-orange-600">
|
|
893
|
+
{t('securityLevelMedium')}
|
|
894
|
+
</span>
|
|
895
|
+
)}
|
|
896
|
+
</CardTitle>
|
|
897
|
+
</div>
|
|
898
|
+
</CardHeader>
|
|
899
|
+
</Card>
|
|
900
|
+
|
|
901
|
+
<Card className="border-l-4 border-l-purple-500 py-2">
|
|
902
|
+
<CardHeader className="p-2">
|
|
903
|
+
<div className="flex flex-col items-center text-center">
|
|
904
|
+
<div className="rounded-full bg-purple-100 p-1 mb-1">
|
|
905
|
+
<Shield className="h-4 w-4 text-purple-600" />
|
|
906
|
+
</div>
|
|
907
|
+
<CardDescription className="text-xs">
|
|
908
|
+
{t('cardMfaMethods')}
|
|
909
|
+
</CardDescription>
|
|
910
|
+
<CardTitle className="text-sm font-bold text-purple-600">
|
|
911
|
+
{editingUser.user_mfa?.length || 0}
|
|
912
|
+
</CardTitle>
|
|
913
|
+
</div>
|
|
914
|
+
</CardHeader>
|
|
915
|
+
</Card>
|
|
916
|
+
|
|
917
|
+
<Card className="border-l-4 border-l-blue-500 py-2">
|
|
918
|
+
<CardHeader className="p-2">
|
|
919
|
+
<div className="flex flex-col items-center text-center">
|
|
920
|
+
<div className="rounded-full bg-blue-100 p-1 mb-1">
|
|
921
|
+
<Fingerprint className="h-4 w-4 text-blue-600" />
|
|
922
|
+
</div>
|
|
923
|
+
<CardDescription className="text-xs">
|
|
924
|
+
{t('cardUserId')}
|
|
925
|
+
</CardDescription>
|
|
926
|
+
<CardTitle className="text-xs font-bold text-blue-600">
|
|
927
|
+
#{editingUser.id}
|
|
928
|
+
</CardTitle>
|
|
929
|
+
</div>
|
|
930
|
+
</CardHeader>
|
|
931
|
+
</Card>
|
|
932
|
+
</div>
|
|
933
|
+
</div>
|
|
934
|
+
|
|
935
|
+
{editingUser.suspended_until && (
|
|
936
|
+
<Card className="border-rose-200 bg-rose-50/50">
|
|
937
|
+
<CardHeader className="p-2">
|
|
938
|
+
<div className="flex items-start gap-2">
|
|
939
|
+
<div className="rounded-full bg-rose-100 p-1">
|
|
940
|
+
<ShieldCheck className="h-3 w-3 text-rose-600" />
|
|
941
|
+
</div>
|
|
942
|
+
<div className="flex-1 space-y-2">
|
|
943
|
+
<div>
|
|
944
|
+
<p className="text-sm font-semibold text-rose-900">
|
|
945
|
+
{t('accountSuspended')}
|
|
946
|
+
</p>
|
|
947
|
+
<p className="text-xs text-rose-700 flex items-center gap-1 mt-1">
|
|
948
|
+
<Clock className="h-3 w-3" />
|
|
949
|
+
{t('until')}: {editingUser.suspended_until}
|
|
950
|
+
</p>
|
|
951
|
+
</div>
|
|
952
|
+
{editingUser.suspended_reason && (
|
|
953
|
+
<div className="rounded-md bg-rose-100 p-2">
|
|
954
|
+
<p className="text-xs font-medium text-rose-900">
|
|
955
|
+
{t('reason')}
|
|
956
|
+
</p>
|
|
957
|
+
<p className="text-xs text-rose-700">
|
|
958
|
+
{editingUser.suspended_reason}
|
|
959
|
+
</p>
|
|
960
|
+
</div>
|
|
961
|
+
)}
|
|
962
|
+
</div>
|
|
963
|
+
</div>
|
|
964
|
+
</CardHeader>
|
|
965
|
+
</Card>
|
|
966
|
+
)}
|
|
967
|
+
|
|
968
|
+
<div className="space-y-3">
|
|
969
|
+
<div className="flex items-center justify-between">
|
|
970
|
+
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
971
|
+
<Shield className="h-4 w-4" />
|
|
972
|
+
{t('recentActivityTitle')}
|
|
973
|
+
</h4>
|
|
974
|
+
</div>
|
|
975
|
+
|
|
976
|
+
<Card className="rounded-md p-0">
|
|
977
|
+
<CardContent className="p-4 gap-2 flex flex-col">
|
|
978
|
+
{editingUser.user_activity?.length ? (
|
|
979
|
+
<div className="space-y-3">
|
|
980
|
+
{editingUser.user_activity
|
|
981
|
+
?.slice(0, 5)
|
|
982
|
+
.map((activity, index) => {
|
|
983
|
+
const actionIcons: Record<string, LucideIcon> =
|
|
984
|
+
{
|
|
985
|
+
login: UserIcon,
|
|
986
|
+
forgotPassword: KeyRound,
|
|
987
|
+
logout: LogOut,
|
|
988
|
+
resetPassword: RefreshCcw,
|
|
989
|
+
revokeAllSessions: ShieldOff,
|
|
990
|
+
revokeSession: Shield,
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const Icon =
|
|
994
|
+
actionIcons[activity.action] || UserIcon;
|
|
995
|
+
|
|
996
|
+
return (
|
|
997
|
+
<div
|
|
998
|
+
key={index}
|
|
999
|
+
className="flex items-start gap-3 border-b pb-3 last:border-none"
|
|
1000
|
+
>
|
|
1001
|
+
<div className="rounded-md bg-blue-50 p-2">
|
|
1002
|
+
<Icon className="w-4 h-4 text-gray-600" />
|
|
1003
|
+
</div>
|
|
1004
|
+
<div className="flex-1">
|
|
1005
|
+
<p className="text-sm font-medium">
|
|
1006
|
+
{userActivityT(activity.action)}
|
|
1007
|
+
</p>
|
|
1008
|
+
<p className="text-[11px] text-gray-500 mt-1">
|
|
1009
|
+
{activity.created_at &&
|
|
1010
|
+
formatDistanceToNow(
|
|
1011
|
+
new Date(activity.created_at),
|
|
1012
|
+
{
|
|
1013
|
+
addSuffix: true,
|
|
1014
|
+
locale:
|
|
1015
|
+
currentLocaleCode === 'en'
|
|
1016
|
+
? enUS
|
|
1017
|
+
: ptBR,
|
|
1018
|
+
}
|
|
1019
|
+
)}
|
|
1020
|
+
</p>
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
);
|
|
1024
|
+
})}
|
|
1025
|
+
</div>
|
|
1026
|
+
) : (
|
|
1027
|
+
<span className="text-sm text-muted-foreground">
|
|
1028
|
+
{t('noRecentActivity')}
|
|
1029
|
+
</span>
|
|
1030
|
+
)}
|
|
1031
|
+
</CardContent>
|
|
1032
|
+
</Card>
|
|
1033
|
+
</div>
|
|
1034
|
+
|
|
1035
|
+
<div className="flex justify-end">
|
|
1036
|
+
<Button
|
|
1037
|
+
className="cursor-pointer"
|
|
1038
|
+
variant="destructive"
|
|
1039
|
+
onClick={() => setOpenDeleteModal(true)}
|
|
1040
|
+
>
|
|
1041
|
+
<Trash2 className="w-4 h-4" />
|
|
1042
|
+
<span>{t('buttonDeleteUser')}</span>
|
|
1043
|
+
</Button>
|
|
1044
|
+
</div>
|
|
1045
|
+
|
|
1046
|
+
<Dialog
|
|
1047
|
+
open={openDeleteModal}
|
|
1048
|
+
onOpenChange={setOpenDeleteModal}
|
|
1049
|
+
>
|
|
1050
|
+
<DialogContent className="sm:max-w-lg">
|
|
1051
|
+
<DialogHeader>
|
|
1052
|
+
<DialogTitle>{t('dialogDeleteUserTitle')}</DialogTitle>
|
|
1053
|
+
<DialogDescription>
|
|
1054
|
+
{t('dialogDeleteUserDescription')}
|
|
1055
|
+
</DialogDescription>
|
|
1056
|
+
</DialogHeader>
|
|
1057
|
+
<hr className="mt-4" />
|
|
1058
|
+
<div className="flex justify-end">
|
|
1059
|
+
<Button
|
|
1060
|
+
type="button"
|
|
1061
|
+
className="px-4 w-28 h-12 py-2 bg-gray-300 text-black hover:bg-gray-300 hover:text-black rounded-sm mr-2 text-md"
|
|
1062
|
+
onClick={() => setOpenDeleteModal(false)}
|
|
1063
|
+
>
|
|
1064
|
+
{t('deleteUserCancel')}
|
|
1065
|
+
</Button>
|
|
1066
|
+
<Button
|
|
1067
|
+
onClick={onDelete}
|
|
1068
|
+
variant="destructive"
|
|
1069
|
+
className="px-4 w-32 h-12 py-2 text-white hover:text-white rounded-sm text-md cursor-pointer"
|
|
1070
|
+
>
|
|
1071
|
+
{t('deleteUserConfirm')}
|
|
1072
|
+
</Button>
|
|
1073
|
+
</div>
|
|
1074
|
+
</DialogContent>
|
|
1075
|
+
</Dialog>
|
|
1076
|
+
</TabsContent>
|
|
1077
|
+
|
|
1078
|
+
<TabsContent value="edit" className="space-y-4 mt-4 p-4 pt-0">
|
|
1079
|
+
<Form {...editForm}>
|
|
1080
|
+
<form
|
|
1081
|
+
onSubmit={editForm.handleSubmit(onEditSubmit)}
|
|
1082
|
+
className="space-y-4"
|
|
1083
|
+
>
|
|
1084
|
+
<FormField
|
|
1085
|
+
control={editForm.control}
|
|
1086
|
+
name="name"
|
|
1087
|
+
render={({ field }) => (
|
|
1088
|
+
<FormItem>
|
|
1089
|
+
<FormLabel>{t('editNameLabel')}</FormLabel>
|
|
1090
|
+
<FormControl>
|
|
1091
|
+
<Input
|
|
1092
|
+
placeholder={t('editNamePlaceholder')}
|
|
1093
|
+
{...field}
|
|
1094
|
+
/>
|
|
1095
|
+
</FormControl>
|
|
1096
|
+
<FormMessage />
|
|
1097
|
+
</FormItem>
|
|
1098
|
+
)}
|
|
1099
|
+
/>
|
|
1100
|
+
<div className="flex flex-col w-full gap-2 pt-2">
|
|
1101
|
+
<Button type="submit" className="w-full">
|
|
1102
|
+
{t('saveChanges')}
|
|
1103
|
+
</Button>
|
|
1104
|
+
<Button
|
|
1105
|
+
className="w-full"
|
|
1106
|
+
type="button"
|
|
1107
|
+
variant="outline"
|
|
1108
|
+
onClick={() => setEditingUser(null)}
|
|
1109
|
+
>
|
|
1110
|
+
{t('cancel')}
|
|
1111
|
+
</Button>
|
|
1112
|
+
</div>
|
|
1113
|
+
</form>
|
|
1114
|
+
</Form>
|
|
1115
|
+
</TabsContent>
|
|
1116
|
+
|
|
1117
|
+
<TabsContent
|
|
1118
|
+
value="credentials"
|
|
1119
|
+
className="space-y-4 mt-4 p-4 pt-0"
|
|
1120
|
+
>
|
|
1121
|
+
<div className="space-y-3">
|
|
1122
|
+
<h4 className="text-sm font-medium">
|
|
1123
|
+
{t('connectedAccountsTitle')}
|
|
1124
|
+
</h4>
|
|
1125
|
+
<div className="space-y-2">
|
|
1126
|
+
{accountProviders.map((provider: any) => {
|
|
1127
|
+
const isConnected = editingUser.user_account?.includes(
|
|
1128
|
+
provider.name.toLowerCase()
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
return (
|
|
1132
|
+
<div
|
|
1133
|
+
key={provider.name}
|
|
1134
|
+
className={`flex items-center justify-between rounded-lg border p-3 ${
|
|
1135
|
+
isConnected ? '' : 'border-dashed'
|
|
1136
|
+
}`}
|
|
1137
|
+
>
|
|
1138
|
+
<div className="flex items-center gap-3">
|
|
1139
|
+
<div className="rounded-md bg-slate-50 p-2">
|
|
1140
|
+
<img
|
|
1141
|
+
src={provider.icon}
|
|
1142
|
+
alt={`${provider.name} icon`}
|
|
1143
|
+
className={`h-5 w-5 ${
|
|
1144
|
+
isConnected ? 'opacity-100' : 'opacity-50'
|
|
1145
|
+
}`}
|
|
1146
|
+
/>
|
|
1147
|
+
</div>
|
|
1148
|
+
<div>
|
|
1149
|
+
<p
|
|
1150
|
+
className={`text-sm font-medium ${
|
|
1151
|
+
isConnected ? '' : 'text-muted-foreground'
|
|
1152
|
+
}`}
|
|
1153
|
+
>
|
|
1154
|
+
{provider.name}
|
|
1155
|
+
</p>
|
|
1156
|
+
<p className="text-xs text-muted-foreground">
|
|
1157
|
+
{isConnected
|
|
1158
|
+
? t('connected')
|
|
1159
|
+
: t('notConnected')}
|
|
1160
|
+
</p>
|
|
1161
|
+
</div>
|
|
1162
|
+
</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
);
|
|
1165
|
+
})}
|
|
1166
|
+
</div>
|
|
1167
|
+
</div>
|
|
1168
|
+
</TabsContent>
|
|
1169
|
+
|
|
1170
|
+
<TabsContent
|
|
1171
|
+
value="identifiers"
|
|
1172
|
+
className="space-y-4 mt-4 p-4 pt-0"
|
|
1173
|
+
>
|
|
1174
|
+
<UserIdentifiersSection editingUser={editingUser} />
|
|
1175
|
+
</TabsContent>
|
|
1176
|
+
|
|
1177
|
+
<TabsContent
|
|
1178
|
+
value="permissions"
|
|
1179
|
+
className="space-y-4 mt-4 p-4 pt-0"
|
|
1180
|
+
>
|
|
1181
|
+
<div className="space-y-3">
|
|
1182
|
+
<div className="flex items-center justify-between">
|
|
1183
|
+
<div>
|
|
1184
|
+
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
1185
|
+
<ShieldCheck className="h-4 w-4" />
|
|
1186
|
+
{t('permissionsTitle')}
|
|
1187
|
+
</h4>
|
|
1188
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
1189
|
+
{t('permissionsDescription')}
|
|
1190
|
+
</p>
|
|
1191
|
+
</div>
|
|
1192
|
+
</div>
|
|
1193
|
+
|
|
1194
|
+
<PermissionsSection
|
|
1195
|
+
userId={editingUser.id!}
|
|
1196
|
+
onRoleChange={handleRefreshEditingUser}
|
|
1197
|
+
/>
|
|
1198
|
+
</div>
|
|
1199
|
+
</TabsContent>
|
|
1200
|
+
|
|
1201
|
+
<TabsContent value="mfa" className="space-y-4 mt-4 p-4 pt-0">
|
|
1202
|
+
<div className="space-y-3">
|
|
1203
|
+
<h4 className="text-sm font-medium">{t('mfaTitle')}</h4>
|
|
1204
|
+
{editingUser.user_mfa && editingUser.user_mfa.length > 0 ? (
|
|
1205
|
+
<div className="space-y-2">
|
|
1206
|
+
{editingUser.user_mfa.map((mfa: UserMfa) => (
|
|
1207
|
+
<div
|
|
1208
|
+
key={String(mfa.id)}
|
|
1209
|
+
className="flex items-center justify-between rounded-lg border p-3"
|
|
1210
|
+
>
|
|
1211
|
+
<div className="flex items-center gap-3">
|
|
1212
|
+
<div className="rounded-md bg-amber-50 p-2">
|
|
1213
|
+
<Shield className="h-5 w-5 text-amber-600" />
|
|
1214
|
+
</div>
|
|
1215
|
+
<div>
|
|
1216
|
+
<p className="text-sm font-medium">
|
|
1217
|
+
{mfa.name}
|
|
1218
|
+
</p>
|
|
1219
|
+
<p className="text-xs text-muted-foreground">
|
|
1220
|
+
{t('mfaEnabled')}
|
|
1221
|
+
</p>
|
|
1222
|
+
</div>
|
|
1223
|
+
</div>
|
|
1224
|
+
</div>
|
|
1225
|
+
))}
|
|
1226
|
+
</div>
|
|
1227
|
+
) : (
|
|
1228
|
+
<div className="rounded-lg border border-dashed p-6 text-center">
|
|
1229
|
+
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
|
1230
|
+
<p className="text-sm font-medium mb-1">
|
|
1231
|
+
{t('noMfaEnabled')}
|
|
1232
|
+
</p>
|
|
1233
|
+
<p className="text-xs text-muted-foreground mb-4">
|
|
1234
|
+
{t('enhanceSecurity')}
|
|
1235
|
+
</p>
|
|
1236
|
+
</div>
|
|
1237
|
+
)}
|
|
1238
|
+
</div>
|
|
1239
|
+
</TabsContent>
|
|
1240
|
+
|
|
1241
|
+
<TabsContent
|
|
1242
|
+
value="sessions"
|
|
1243
|
+
className="space-y-4 mt-4 p-4 pt-0"
|
|
1244
|
+
>
|
|
1245
|
+
<ActiveSessions
|
|
1246
|
+
editingUser={editingUser}
|
|
1247
|
+
refetch={handleRefreshEditingUser}
|
|
1248
|
+
/>
|
|
1249
|
+
</TabsContent>
|
|
1250
|
+
</Tabs>
|
|
1251
|
+
</SheetContent>
|
|
1252
|
+
</Sheet>
|
|
1253
|
+
)}
|
|
1254
|
+
</div>
|
|
1255
|
+
</div>
|
|
1256
|
+
);
|
|
1257
|
+
}
|