@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,397 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
5
|
+
import { Input } from '@/components/ui/input';
|
|
6
|
+
import { Label } from '@/components/ui/label';
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
} from '@/components/ui/select';
|
|
14
|
+
import { Switch } from '@/components/ui/switch';
|
|
15
|
+
import { useDebounce } from '@/hooks/use-debounce';
|
|
16
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
17
|
+
import {
|
|
18
|
+
ChevronLeft,
|
|
19
|
+
ChevronRight,
|
|
20
|
+
ChevronsLeft,
|
|
21
|
+
ChevronsRight,
|
|
22
|
+
Loader2,
|
|
23
|
+
Route as RouteIcon,
|
|
24
|
+
Search,
|
|
25
|
+
} from 'lucide-react';
|
|
26
|
+
import { useTranslations } from 'next-intl';
|
|
27
|
+
import { useState } from 'react';
|
|
28
|
+
import { toast } from 'sonner';
|
|
29
|
+
|
|
30
|
+
type RouteType = {
|
|
31
|
+
id: number;
|
|
32
|
+
url: string;
|
|
33
|
+
method: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
role_route?: Array<{
|
|
36
|
+
route_id: number;
|
|
37
|
+
role_id: number;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type RoleRoutesSectionProps = {
|
|
42
|
+
roleId: number;
|
|
43
|
+
onRouteChange?: () => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function RoleRoutesSection({
|
|
47
|
+
roleId,
|
|
48
|
+
onRouteChange,
|
|
49
|
+
}: RoleRoutesSectionProps) {
|
|
50
|
+
const t = useTranslations('core.RolePage');
|
|
51
|
+
const { request } = useApp();
|
|
52
|
+
const [togglingRouteId, setTogglingRouteId] = useState<number | null>(null);
|
|
53
|
+
const [page, setPage] = useState(1);
|
|
54
|
+
const [pageSize, setPageSize] = useState(10);
|
|
55
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
56
|
+
const [searchType, setSearchType] = useState<
|
|
57
|
+
'contains' | 'startsWith' | 'endsWith'
|
|
58
|
+
>('contains');
|
|
59
|
+
const [methodFilter, setMethodFilter] = useState<string>('all');
|
|
60
|
+
const debouncedSearch = useDebounce(searchTerm);
|
|
61
|
+
|
|
62
|
+
const {
|
|
63
|
+
data: assignedRoutesData,
|
|
64
|
+
isLoading: isLoadingAssigned,
|
|
65
|
+
refetch: refetchAssignedRoutes,
|
|
66
|
+
} = useQuery<{ data: RouteType[] }>({
|
|
67
|
+
queryKey: ['role-routes-assigned', roleId],
|
|
68
|
+
queryFn: async () => {
|
|
69
|
+
const response = await request<{ data: RouteType[] }>({
|
|
70
|
+
url: `/role/${roleId}/route?pageSize=10000`,
|
|
71
|
+
method: 'GET',
|
|
72
|
+
});
|
|
73
|
+
return response.data;
|
|
74
|
+
},
|
|
75
|
+
enabled: !!roleId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const {
|
|
79
|
+
data: routesData,
|
|
80
|
+
isLoading: isLoadingRoutes,
|
|
81
|
+
refetch: refetchRoutes,
|
|
82
|
+
} = useQuery<{ data: RouteType[]; total: number; lastPage: number }>({
|
|
83
|
+
queryKey: [
|
|
84
|
+
'role-routes-paginated',
|
|
85
|
+
roleId,
|
|
86
|
+
page,
|
|
87
|
+
pageSize,
|
|
88
|
+
debouncedSearch,
|
|
89
|
+
searchType,
|
|
90
|
+
methodFilter,
|
|
91
|
+
],
|
|
92
|
+
queryFn: async () => {
|
|
93
|
+
const params = new URLSearchParams();
|
|
94
|
+
params.set('page', String(page));
|
|
95
|
+
params.set('pageSize', String(pageSize));
|
|
96
|
+
if (debouncedSearch) {
|
|
97
|
+
params.set('search', debouncedSearch);
|
|
98
|
+
params.set('searchType', searchType);
|
|
99
|
+
}
|
|
100
|
+
if (methodFilter && methodFilter !== 'all') {
|
|
101
|
+
params.set('method', methodFilter);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const response = await request<{
|
|
105
|
+
data: RouteType[];
|
|
106
|
+
total: number;
|
|
107
|
+
lastPage: number;
|
|
108
|
+
}>({
|
|
109
|
+
url: `/role/${roleId}/route?${params.toString()}`,
|
|
110
|
+
method: 'GET',
|
|
111
|
+
});
|
|
112
|
+
return response.data;
|
|
113
|
+
},
|
|
114
|
+
enabled: !!roleId,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const routes = routesData?.data || [];
|
|
118
|
+
const totalPages = routesData?.lastPage || 1;
|
|
119
|
+
const totalRoutes = routesData?.total || 0;
|
|
120
|
+
|
|
121
|
+
const handleToggleRoute = async (routeId: number, isAssigned: boolean) => {
|
|
122
|
+
setTogglingRouteId(routeId);
|
|
123
|
+
try {
|
|
124
|
+
const currentRouteIds =
|
|
125
|
+
assignedRoutesData?.data
|
|
126
|
+
?.filter((r: RouteType) => r.role_route && r.role_route.length > 0)
|
|
127
|
+
.map((r: RouteType) => r.id) || [];
|
|
128
|
+
|
|
129
|
+
let newRouteIds: number[];
|
|
130
|
+
if (isAssigned) {
|
|
131
|
+
newRouteIds = currentRouteIds.filter((id: number) => id !== routeId);
|
|
132
|
+
} else {
|
|
133
|
+
newRouteIds = [...currentRouteIds, routeId];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
await request({
|
|
137
|
+
url: `/role/${roleId}/route`,
|
|
138
|
+
method: 'PATCH',
|
|
139
|
+
data: { ids: newRouteIds },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
toast.success(isAssigned ? t('routeRemoved') : t('routeAssigned'));
|
|
143
|
+
await refetchAssignedRoutes();
|
|
144
|
+
await refetchRoutes();
|
|
145
|
+
onRouteChange?.();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
toast.error(
|
|
148
|
+
isAssigned ? t('errorRemovingRoute') : t('errorAssigningRoute')
|
|
149
|
+
);
|
|
150
|
+
} finally {
|
|
151
|
+
setTogglingRouteId(null);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const isRouteAssigned = (route: RouteType) => {
|
|
156
|
+
const assignedRoute = assignedRoutesData?.data?.find(
|
|
157
|
+
(r: RouteType) => r.id === route.id
|
|
158
|
+
);
|
|
159
|
+
return !!(assignedRoute?.role_route && assignedRoute.role_route.length > 0);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const getMethodColor = (method: string) => {
|
|
163
|
+
const colors: Record<string, string> = {
|
|
164
|
+
GET: 'bg-blue-100 text-blue-700',
|
|
165
|
+
POST: 'bg-green-100 text-green-700',
|
|
166
|
+
PUT: 'bg-amber-100 text-amber-700',
|
|
167
|
+
PATCH: 'bg-purple-100 text-purple-700',
|
|
168
|
+
DELETE: 'bg-red-100 text-red-700',
|
|
169
|
+
};
|
|
170
|
+
return colors[method.toUpperCase()] || 'bg-gray-100 text-gray-700';
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (isLoadingRoutes || isLoadingAssigned) {
|
|
174
|
+
return (
|
|
175
|
+
<div className="flex items-center justify-center py-8">
|
|
176
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
177
|
+
<span className="ml-2 text-sm text-muted-foreground">
|
|
178
|
+
{t('loadingRoutes')}
|
|
179
|
+
</span>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return (
|
|
184
|
+
<div className="space-y-3">
|
|
185
|
+
<div>
|
|
186
|
+
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
187
|
+
<RouteIcon className="h-4 w-4" />
|
|
188
|
+
{t('routesTitle')}
|
|
189
|
+
</h4>
|
|
190
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
191
|
+
{t('routesDescription')}
|
|
192
|
+
</p>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="space-y-2">
|
|
196
|
+
<div className="relative">
|
|
197
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
198
|
+
<Input
|
|
199
|
+
placeholder={t('searchRoutes')}
|
|
200
|
+
value={searchTerm}
|
|
201
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
202
|
+
className="pl-10"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="w-full flex gap-2">
|
|
206
|
+
<Select
|
|
207
|
+
value={searchType}
|
|
208
|
+
onValueChange={(value: any) => setSearchType(value)}
|
|
209
|
+
>
|
|
210
|
+
<SelectTrigger className="w-full">
|
|
211
|
+
<SelectValue placeholder={t('selectSearchType')} />
|
|
212
|
+
</SelectTrigger>
|
|
213
|
+
<SelectContent>
|
|
214
|
+
<SelectItem value="contains">{t('searchContains')}</SelectItem>
|
|
215
|
+
<SelectItem value="startsWith">
|
|
216
|
+
{t('searchStartsWith')}
|
|
217
|
+
</SelectItem>
|
|
218
|
+
<SelectItem value="endsWith">{t('searchEndsWith')}</SelectItem>
|
|
219
|
+
</SelectContent>
|
|
220
|
+
</Select>
|
|
221
|
+
<Select value={methodFilter} onValueChange={setMethodFilter}>
|
|
222
|
+
<SelectTrigger className="w-full">
|
|
223
|
+
<SelectValue placeholder={t('selectMethod')} />
|
|
224
|
+
</SelectTrigger>
|
|
225
|
+
<SelectContent>
|
|
226
|
+
<SelectItem value="all">{t('allMethods')}</SelectItem>
|
|
227
|
+
<SelectItem value="GET">GET</SelectItem>
|
|
228
|
+
<SelectItem value="POST">POST</SelectItem>
|
|
229
|
+
<SelectItem value="PATCH">PATCH</SelectItem>
|
|
230
|
+
<SelectItem value="PUT">PUT</SelectItem>
|
|
231
|
+
<SelectItem value="DELETE">DELETE</SelectItem>
|
|
232
|
+
</SelectContent>
|
|
233
|
+
</Select>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div className="space-y-2">
|
|
238
|
+
{Boolean(routes.length) ? (
|
|
239
|
+
routes.map((route) => {
|
|
240
|
+
const isAssigned = isRouteAssigned(route);
|
|
241
|
+
const isToggling = togglingRouteId === route.id;
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<Card
|
|
245
|
+
key={route.id}
|
|
246
|
+
className={`transition-all ${
|
|
247
|
+
isAssigned
|
|
248
|
+
? 'border-primary bg-primary/5'
|
|
249
|
+
: 'border-border hover:border-primary/50'
|
|
250
|
+
}`}
|
|
251
|
+
>
|
|
252
|
+
<CardContent className="flex items-center justify-between">
|
|
253
|
+
<div className="flex items-center gap-3 flex-1">
|
|
254
|
+
<div
|
|
255
|
+
className={`rounded-md ${
|
|
256
|
+
isAssigned ? 'bg-primary/10' : 'bg-muted'
|
|
257
|
+
}`}
|
|
258
|
+
>
|
|
259
|
+
<RouteIcon
|
|
260
|
+
className={`h-5 w-5 ${
|
|
261
|
+
isAssigned ? 'text-primary' : 'text-muted-foreground'
|
|
262
|
+
}`}
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="flex-1">
|
|
266
|
+
<div className="flex items-center gap-2">
|
|
267
|
+
<span
|
|
268
|
+
className={`text-[10px] font-bold px-2 py-0.5 rounded ${getMethodColor(
|
|
269
|
+
route.method
|
|
270
|
+
)}`}
|
|
271
|
+
>
|
|
272
|
+
{route.method}
|
|
273
|
+
</span>
|
|
274
|
+
<Label
|
|
275
|
+
htmlFor={`route-${route.id}`}
|
|
276
|
+
className="text-sm font-medium cursor-pointer"
|
|
277
|
+
>
|
|
278
|
+
{route.url}
|
|
279
|
+
</Label>
|
|
280
|
+
</div>
|
|
281
|
+
{route.description && (
|
|
282
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
283
|
+
{route.description}
|
|
284
|
+
</p>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
<Switch
|
|
289
|
+
id={`route-${route.id}`}
|
|
290
|
+
checked={isAssigned}
|
|
291
|
+
disabled={isToggling}
|
|
292
|
+
onCheckedChange={() =>
|
|
293
|
+
handleToggleRoute(route.id, isAssigned)
|
|
294
|
+
}
|
|
295
|
+
className="ml-4"
|
|
296
|
+
/>
|
|
297
|
+
</CardContent>
|
|
298
|
+
</Card>
|
|
299
|
+
);
|
|
300
|
+
})
|
|
301
|
+
) : (
|
|
302
|
+
<Card className="border-dashed">
|
|
303
|
+
<CardContent className="flex flex-col items-center justify-center py-8">
|
|
304
|
+
<RouteIcon className="h-12 w-12 text-muted-foreground mb-3" />
|
|
305
|
+
<p className="text-sm font-medium text-center">
|
|
306
|
+
{t('noRoutesAvailable')}
|
|
307
|
+
</p>
|
|
308
|
+
</CardContent>
|
|
309
|
+
</Card>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<div className="flex items-center justify-between px-2 pt-4">
|
|
314
|
+
<div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
|
|
315
|
+
{t('showing')} {routes.length} {t('of')} {totalRoutes} {t('routes')}
|
|
316
|
+
</div>
|
|
317
|
+
<div className="flex w-full items-center gap-8 lg:w-fit">
|
|
318
|
+
<div className="hidden items-center gap-2 lg:flex">
|
|
319
|
+
<Label
|
|
320
|
+
htmlFor="rows-per-page-routes"
|
|
321
|
+
className="text-sm font-medium"
|
|
322
|
+
>
|
|
323
|
+
{t('rowsPerPage')}
|
|
324
|
+
</Label>
|
|
325
|
+
<Select
|
|
326
|
+
value={`${pageSize}`}
|
|
327
|
+
onValueChange={(value) => {
|
|
328
|
+
setPageSize(Number(value));
|
|
329
|
+
setPage(1);
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
<SelectTrigger
|
|
333
|
+
size="sm"
|
|
334
|
+
className="w-20"
|
|
335
|
+
id="rows-per-page-routes"
|
|
336
|
+
>
|
|
337
|
+
<SelectValue placeholder={pageSize} />
|
|
338
|
+
</SelectTrigger>
|
|
339
|
+
<SelectContent side="top">
|
|
340
|
+
{[10, 20, 30, 40, 50].map((size) => (
|
|
341
|
+
<SelectItem key={size} value={`${size}`}>
|
|
342
|
+
{size}
|
|
343
|
+
</SelectItem>
|
|
344
|
+
))}
|
|
345
|
+
</SelectContent>
|
|
346
|
+
</Select>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
|
349
|
+
{t('page')} {page} {t('of')} {totalPages}
|
|
350
|
+
</div>
|
|
351
|
+
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
|
352
|
+
<Button
|
|
353
|
+
variant="outline"
|
|
354
|
+
className="hidden size-8 lg:flex"
|
|
355
|
+
size="icon"
|
|
356
|
+
onClick={() => setPage(1)}
|
|
357
|
+
disabled={page === 1}
|
|
358
|
+
>
|
|
359
|
+
<span className="sr-only">{t('goToFirstPage')}</span>
|
|
360
|
+
<ChevronsLeft className="size-4" />
|
|
361
|
+
</Button>
|
|
362
|
+
<Button
|
|
363
|
+
variant="outline"
|
|
364
|
+
className="size-8"
|
|
365
|
+
size="icon"
|
|
366
|
+
onClick={() => setPage(page - 1)}
|
|
367
|
+
disabled={page === 1}
|
|
368
|
+
>
|
|
369
|
+
<span className="sr-only">{t('goToPreviousPage')}</span>
|
|
370
|
+
<ChevronLeft className="size-4" />
|
|
371
|
+
</Button>
|
|
372
|
+
<Button
|
|
373
|
+
variant="outline"
|
|
374
|
+
className="size-8"
|
|
375
|
+
size="icon"
|
|
376
|
+
onClick={() => setPage(page + 1)}
|
|
377
|
+
disabled={page >= totalPages}
|
|
378
|
+
>
|
|
379
|
+
<span className="sr-only">{t('goToNextPage')}</span>
|
|
380
|
+
<ChevronRight className="size-4" />
|
|
381
|
+
</Button>
|
|
382
|
+
<Button
|
|
383
|
+
variant="outline"
|
|
384
|
+
className="hidden size-8 lg:flex"
|
|
385
|
+
size="icon"
|
|
386
|
+
onClick={() => setPage(totalPages)}
|
|
387
|
+
disabled={page >= totalPages}
|
|
388
|
+
>
|
|
389
|
+
<span className="sr-only">{t('goToLastPage')}</span>
|
|
390
|
+
<ChevronsRight className="size-4" />
|
|
391
|
+
</Button>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
5
|
+
import { Input } from '@/components/ui/input';
|
|
6
|
+
import { Label } from '@/components/ui/label';
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
} from '@/components/ui/select';
|
|
14
|
+
import { Switch } from '@/components/ui/switch';
|
|
15
|
+
import { useDebounce } from '@/hooks/use-debounce';
|
|
16
|
+
import { getPhotoUrl } from '@/lib/get-photo-url';
|
|
17
|
+
import { getUserEmail } from '@/lib/get-user-email';
|
|
18
|
+
import { User } from '@hed-hog/api-types';
|
|
19
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
20
|
+
import {
|
|
21
|
+
ChevronLeft,
|
|
22
|
+
ChevronRight,
|
|
23
|
+
ChevronsLeft,
|
|
24
|
+
ChevronsRight,
|
|
25
|
+
Loader2,
|
|
26
|
+
Search,
|
|
27
|
+
UserCircle,
|
|
28
|
+
} from 'lucide-react';
|
|
29
|
+
import { useTranslations } from 'next-intl';
|
|
30
|
+
import { useState } from 'react';
|
|
31
|
+
import { toast } from 'sonner';
|
|
32
|
+
|
|
33
|
+
type RoleUsersSectionProps = {
|
|
34
|
+
roleId: number;
|
|
35
|
+
onUserChange?: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function RoleUsersSection({
|
|
39
|
+
roleId,
|
|
40
|
+
onUserChange,
|
|
41
|
+
}: RoleUsersSectionProps) {
|
|
42
|
+
const t = useTranslations('core.RolePage');
|
|
43
|
+
const { request } = useApp();
|
|
44
|
+
const [togglingUserId, setTogglingUserId] = useState<number | null>(null);
|
|
45
|
+
const [page, setPage] = useState(1);
|
|
46
|
+
const [pageSize, setPageSize] = useState(10);
|
|
47
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
48
|
+
const debouncedSearch = useDebounce(searchTerm);
|
|
49
|
+
|
|
50
|
+
const {
|
|
51
|
+
data: assignedUsersData,
|
|
52
|
+
isLoading: isLoadingAssigned,
|
|
53
|
+
refetch: refetchAssignedUsers,
|
|
54
|
+
} = useQuery<{ data: User[] }>({
|
|
55
|
+
queryKey: ['role-users-assigned', roleId],
|
|
56
|
+
queryFn: async () => {
|
|
57
|
+
const response = await request<{ data: User[] }>({
|
|
58
|
+
url: `/role/${roleId}/user?pageSize=10000`,
|
|
59
|
+
method: 'GET',
|
|
60
|
+
});
|
|
61
|
+
return response.data;
|
|
62
|
+
},
|
|
63
|
+
enabled: !!roleId,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const {
|
|
67
|
+
data: allUsersData,
|
|
68
|
+
isLoading: isLoadingAllUsers,
|
|
69
|
+
refetch: refetchAllUsers,
|
|
70
|
+
} = useQuery<{ paginate: { data: User[]; total: number; lastPage: number } }>(
|
|
71
|
+
{
|
|
72
|
+
queryKey: ['all-users-paginated', page, pageSize, debouncedSearch],
|
|
73
|
+
queryFn: async () => {
|
|
74
|
+
const params = new URLSearchParams();
|
|
75
|
+
params.set('page', String(page));
|
|
76
|
+
params.set('pageSize', String(pageSize));
|
|
77
|
+
if (debouncedSearch) params.set('search', debouncedSearch);
|
|
78
|
+
|
|
79
|
+
const response = await request<{
|
|
80
|
+
paginate: { data: User[]; total: number; lastPage: number };
|
|
81
|
+
}>({
|
|
82
|
+
url: `/user?${params.toString()}`,
|
|
83
|
+
method: 'GET',
|
|
84
|
+
});
|
|
85
|
+
return response.data;
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const allUsers = allUsersData?.paginate?.data || [];
|
|
91
|
+
const totalPages = allUsersData?.paginate?.lastPage || 1;
|
|
92
|
+
const totalUsers = allUsersData?.paginate?.total || 0;
|
|
93
|
+
|
|
94
|
+
const handleToggleUser = async (userId: number, isAssigned: boolean) => {
|
|
95
|
+
setTogglingUserId(userId);
|
|
96
|
+
try {
|
|
97
|
+
const currentUserIds =
|
|
98
|
+
assignedUsersData?.data
|
|
99
|
+
?.filter((u: User) => u.role_user && u.role_user.length > 0)
|
|
100
|
+
.map((u: User) => u.id) || [];
|
|
101
|
+
|
|
102
|
+
let newUserIds: number[];
|
|
103
|
+
if (isAssigned) {
|
|
104
|
+
newUserIds = currentUserIds.filter(
|
|
105
|
+
(id): id is number => typeof id === 'number' && id !== userId
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
newUserIds = [...currentUserIds, userId].filter(
|
|
109
|
+
(id): id is number => typeof id === 'number'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await request({
|
|
114
|
+
url: `/role/${roleId}/user`,
|
|
115
|
+
method: 'PATCH',
|
|
116
|
+
data: { ids: newUserIds.filter(Boolean) },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
toast.success(isAssigned ? t('userRemoved') : t('userAssigned'));
|
|
120
|
+
await refetchAssignedUsers();
|
|
121
|
+
await refetchAllUsers();
|
|
122
|
+
onUserChange?.();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
toast.error(
|
|
125
|
+
isAssigned ? t('errorRemovingUser') : t('errorAssigningUser')
|
|
126
|
+
);
|
|
127
|
+
} finally {
|
|
128
|
+
setTogglingUserId(null);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const isUserAssigned = (userId: number) => {
|
|
133
|
+
const user = assignedUsersData?.data?.find((u: User) => u.id === userId);
|
|
134
|
+
return !!(user?.role_user && user.role_user.length > 0);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (isLoadingAllUsers || isLoadingAssigned) {
|
|
138
|
+
return (
|
|
139
|
+
<div className="flex items-center justify-center py-8">
|
|
140
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
141
|
+
<span className="ml-2 text-sm text-muted-foreground">
|
|
142
|
+
{t('loadingUsers')}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="space-y-3">
|
|
150
|
+
<div>
|
|
151
|
+
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
152
|
+
<UserCircle className="h-4 w-4" />
|
|
153
|
+
{t('usersTitle')}
|
|
154
|
+
</h4>
|
|
155
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
156
|
+
{t('usersDescription')}
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="relative">
|
|
161
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
162
|
+
<Input
|
|
163
|
+
placeholder={t('searchUsers')}
|
|
164
|
+
value={searchTerm}
|
|
165
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
166
|
+
className="pl-10"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div className="space-y-2">
|
|
171
|
+
{Boolean(allUsers.length) ? (
|
|
172
|
+
allUsers.map((user) => {
|
|
173
|
+
const isAssigned = isUserAssigned(user.id!);
|
|
174
|
+
const isToggling = togglingUserId === user.id;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Card
|
|
178
|
+
key={user.id}
|
|
179
|
+
className={`transition-all ${
|
|
180
|
+
isAssigned
|
|
181
|
+
? 'border-primary bg-primary/5'
|
|
182
|
+
: 'border-border hover:border-primary/50'
|
|
183
|
+
}`}
|
|
184
|
+
>
|
|
185
|
+
<CardContent className="flex items-center justify-between">
|
|
186
|
+
<div className="flex items-center gap-3 flex-1">
|
|
187
|
+
<img
|
|
188
|
+
src={getPhotoUrl(user.photo_id)}
|
|
189
|
+
alt={user.name}
|
|
190
|
+
className="h-10 w-10 rounded-full object-cover"
|
|
191
|
+
/>
|
|
192
|
+
<div className="flex-1">
|
|
193
|
+
<Label
|
|
194
|
+
htmlFor={`user-${user.id}`}
|
|
195
|
+
className="text-sm font-medium cursor-pointer"
|
|
196
|
+
>
|
|
197
|
+
{user.name}
|
|
198
|
+
</Label>
|
|
199
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
200
|
+
{getUserEmail(user)}
|
|
201
|
+
</p>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<Switch
|
|
205
|
+
id={`user-${user.id}`}
|
|
206
|
+
checked={isAssigned}
|
|
207
|
+
disabled={isToggling}
|
|
208
|
+
onCheckedChange={() =>
|
|
209
|
+
handleToggleUser(user.id!, isAssigned)
|
|
210
|
+
}
|
|
211
|
+
className="ml-4"
|
|
212
|
+
/>
|
|
213
|
+
</CardContent>
|
|
214
|
+
</Card>
|
|
215
|
+
);
|
|
216
|
+
})
|
|
217
|
+
) : (
|
|
218
|
+
<Card className="border-dashed">
|
|
219
|
+
<CardContent className="flex flex-col items-center justify-center py-8">
|
|
220
|
+
<UserCircle className="h-12 w-12 text-muted-foreground mb-3" />
|
|
221
|
+
<p className="text-sm font-medium text-center">
|
|
222
|
+
{t('noUsersAvailable')}
|
|
223
|
+
</p>
|
|
224
|
+
</CardContent>
|
|
225
|
+
</Card>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="flex items-center justify-between px-2 pt-4">
|
|
230
|
+
<div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
|
|
231
|
+
{t('showing')} {allUsers.length} {t('of')} {totalUsers} {t('users')}
|
|
232
|
+
</div>
|
|
233
|
+
<div className="flex w-full items-center gap-8 lg:w-fit">
|
|
234
|
+
<div className="hidden items-center gap-2 lg:flex">
|
|
235
|
+
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
|
236
|
+
{t('rowsPerPage')}
|
|
237
|
+
</Label>
|
|
238
|
+
<Select
|
|
239
|
+
value={`${pageSize}`}
|
|
240
|
+
onValueChange={(value) => {
|
|
241
|
+
setPageSize(Number(value));
|
|
242
|
+
setPage(1);
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
|
246
|
+
<SelectValue placeholder={pageSize} />
|
|
247
|
+
</SelectTrigger>
|
|
248
|
+
<SelectContent side="top">
|
|
249
|
+
{[10, 20, 30, 40, 50].map((size) => (
|
|
250
|
+
<SelectItem key={size} value={`${size}`}>
|
|
251
|
+
{size}
|
|
252
|
+
</SelectItem>
|
|
253
|
+
))}
|
|
254
|
+
</SelectContent>
|
|
255
|
+
</Select>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
|
258
|
+
{t('page')} {page} {t('of')} {totalPages}
|
|
259
|
+
</div>
|
|
260
|
+
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
|
261
|
+
<Button
|
|
262
|
+
variant="outline"
|
|
263
|
+
className="hidden size-8 lg:flex"
|
|
264
|
+
size="icon"
|
|
265
|
+
onClick={() => setPage(1)}
|
|
266
|
+
disabled={page === 1}
|
|
267
|
+
>
|
|
268
|
+
<span className="sr-only">{t('goToFirstPage')}</span>
|
|
269
|
+
<ChevronsLeft className="size-4" />
|
|
270
|
+
</Button>
|
|
271
|
+
<Button
|
|
272
|
+
variant="outline"
|
|
273
|
+
className="size-8"
|
|
274
|
+
size="icon"
|
|
275
|
+
onClick={() => setPage(page - 1)}
|
|
276
|
+
disabled={page === 1}
|
|
277
|
+
>
|
|
278
|
+
<span className="sr-only">{t('goToPreviousPage')}</span>
|
|
279
|
+
<ChevronLeft className="size-4" />
|
|
280
|
+
</Button>
|
|
281
|
+
<Button
|
|
282
|
+
variant="outline"
|
|
283
|
+
className="size-8"
|
|
284
|
+
size="icon"
|
|
285
|
+
onClick={() => setPage(page + 1)}
|
|
286
|
+
disabled={page >= totalPages}
|
|
287
|
+
>
|
|
288
|
+
<span className="sr-only">{t('goToNextPage')}</span>
|
|
289
|
+
<ChevronRight className="size-4" />
|
|
290
|
+
</Button>
|
|
291
|
+
<Button
|
|
292
|
+
variant="outline"
|
|
293
|
+
className="hidden size-8 lg:flex"
|
|
294
|
+
size="icon"
|
|
295
|
+
onClick={() => setPage(totalPages)}
|
|
296
|
+
disabled={page >= totalPages}
|
|
297
|
+
>
|
|
298
|
+
<span className="sr-only">{t('goToLastPage')}</span>
|
|
299
|
+
<ChevronsRight className="size-4" />
|
|
300
|
+
</Button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|