@hed-hog/core 0.0.349 → 0.0.350

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.
Files changed (126) hide show
  1. package/dist/auth/auth.controller.d.ts +3 -3
  2. package/dist/auth/auth.service.d.ts +8 -8
  3. package/dist/core.module.d.ts.map +1 -1
  4. package/dist/core.module.js +3 -0
  5. package/dist/core.module.js.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +4 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mcp/guards/mcp-auth.guard.d.ts +6 -1
  11. package/dist/mcp/guards/mcp-auth.guard.d.ts.map +1 -1
  12. package/dist/mcp/guards/mcp-auth.guard.js +29 -2
  13. package/dist/mcp/guards/mcp-auth.guard.js.map +1 -1
  14. package/dist/mcp/mcp-api-key.controller.d.ts +32 -0
  15. package/dist/mcp/mcp-api-key.controller.d.ts.map +1 -0
  16. package/dist/mcp/mcp-api-key.controller.js +64 -0
  17. package/dist/mcp/mcp-api-key.controller.js.map +1 -0
  18. package/dist/mcp/mcp-api-key.service.d.ts +27 -0
  19. package/dist/mcp/mcp-api-key.service.d.ts.map +1 -0
  20. package/dist/mcp/mcp-api-key.service.js +74 -0
  21. package/dist/mcp/mcp-api-key.service.js.map +1 -0
  22. package/dist/mcp/mcp-registry.service.d.ts +1 -2
  23. package/dist/mcp/mcp-registry.service.d.ts.map +1 -1
  24. package/dist/mcp/mcp-registry.service.js +6 -2
  25. package/dist/mcp/mcp-registry.service.js.map +1 -1
  26. package/dist/mcp/mcp.module.d.ts.map +1 -1
  27. package/dist/mcp/mcp.module.js +8 -1
  28. package/dist/mcp/mcp.module.js.map +1 -1
  29. package/dist/mcp-chat/mcp-chat.controller.d.ts +4 -0
  30. package/dist/mcp-chat/mcp-chat.controller.d.ts.map +1 -1
  31. package/dist/mcp-chat/mcp-chat.controller.js +46 -0
  32. package/dist/mcp-chat/mcp-chat.controller.js.map +1 -1
  33. package/dist/mcp-chat/mcp-chat.service.d.ts +6 -1
  34. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  35. package/dist/mcp-chat/mcp-chat.service.js +125 -17
  36. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  37. package/dist/notification/dto/create-notification.dto.d.ts +26 -0
  38. package/dist/notification/dto/create-notification.dto.d.ts.map +1 -0
  39. package/dist/notification/dto/create-notification.dto.js +86 -0
  40. package/dist/notification/dto/create-notification.dto.js.map +1 -0
  41. package/dist/notification/dto/update-progress.dto.d.ts +6 -0
  42. package/dist/notification/dto/update-progress.dto.d.ts.map +1 -0
  43. package/dist/notification/dto/update-progress.dto.js +33 -0
  44. package/dist/notification/dto/update-progress.dto.js.map +1 -0
  45. package/dist/notification/notification.controller.d.ts +108 -0
  46. package/dist/notification/notification.controller.d.ts.map +1 -0
  47. package/dist/notification/notification.controller.js +118 -0
  48. package/dist/notification/notification.controller.js.map +1 -0
  49. package/dist/notification/notification.module.d.ts +3 -0
  50. package/dist/notification/notification.module.d.ts.map +1 -0
  51. package/dist/notification/notification.module.js +29 -0
  52. package/dist/notification/notification.module.js.map +1 -0
  53. package/dist/notification/notification.service.d.ts +98 -0
  54. package/dist/notification/notification.service.d.ts.map +1 -0
  55. package/dist/notification/notification.service.js +123 -0
  56. package/dist/notification/notification.service.js.map +1 -0
  57. package/dist/profile/profile.controller.d.ts +1 -1
  58. package/dist/profile/profile.service.d.ts +1 -1
  59. package/dist/session/session.controller.d.ts +1 -1
  60. package/dist/session/session.service.d.ts +3 -3
  61. package/dist/setting/dto/update-setting-list.dto.d.ts +9 -0
  62. package/dist/setting/dto/update-setting-list.dto.d.ts.map +1 -0
  63. package/dist/setting/dto/update-setting-list.dto.js +44 -0
  64. package/dist/setting/dto/update-setting-list.dto.js.map +1 -0
  65. package/dist/setting/setting.controller.d.ts +19 -0
  66. package/dist/setting/setting.controller.d.ts.map +1 -1
  67. package/dist/setting/setting.controller.js +12 -0
  68. package/dist/setting/setting.controller.js.map +1 -1
  69. package/dist/setting/setting.service.d.ts +19 -0
  70. package/dist/setting/setting.service.d.ts.map +1 -1
  71. package/dist/setting/setting.service.js +79 -0
  72. package/dist/setting/setting.service.js.map +1 -1
  73. package/dist/task/task.service.d.ts +12 -0
  74. package/dist/task/task.service.d.ts.map +1 -1
  75. package/dist/task/task.service.js +60 -0
  76. package/dist/task/task.service.js.map +1 -1
  77. package/dist/user/user.controller.d.ts +2 -2
  78. package/dist/user/user.service.d.ts +6 -6
  79. package/dist/webhook-integration/webhook-integration.service.d.ts +4 -0
  80. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  81. package/dist/webhook-integration/webhook-integration.service.js +184 -34
  82. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  83. package/dist/webhook-integration/webhook-utils.d.ts +1 -0
  84. package/dist/webhook-integration/webhook-utils.d.ts.map +1 -1
  85. package/dist/webhook-integration/webhook-utils.js +38 -0
  86. package/dist/webhook-integration/webhook-utils.js.map +1 -1
  87. package/hedhog/data/menu.yaml +48 -4
  88. package/hedhog/data/route.yaml +133 -0
  89. package/hedhog/data/setting_group.yaml +45 -0
  90. package/hedhog/data/setting_subgroup.yaml +16 -0
  91. package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +285 -0
  92. package/hedhog/frontend/app/account/layout.tsx.ejs +32 -55
  93. package/hedhog/frontend/app/account/mcp-tokens/page.tsx.ejs +5 -0
  94. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +2098 -1835
  95. package/hedhog/frontend/app/mcp_chat/chat-components.tsx.ejs +595 -0
  96. package/hedhog/frontend/app/mcp_chat/chat-types.ts.ejs +16 -0
  97. package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +554 -383
  98. package/hedhog/frontend/app/setting-list/page.tsx.ejs +649 -0
  99. package/hedhog/frontend/app/users/page.tsx.ejs +445 -203
  100. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +16 -37
  101. package/hedhog/frontend/messages/en.json +241 -2
  102. package/hedhog/frontend/messages/pt.json +241 -2
  103. package/hedhog/table/mcp_api_key.yaml +30 -0
  104. package/hedhog/table/notification.yaml +72 -0
  105. package/hedhog/table/webhook_action_log.yaml +49 -0
  106. package/package.json +4 -4
  107. package/src/core.module.ts +3 -0
  108. package/src/index.ts +5 -0
  109. package/src/mcp/guards/mcp-auth.guard.ts +64 -32
  110. package/src/mcp/mcp-api-key.controller.ts +42 -0
  111. package/src/mcp/mcp-api-key.service.ts +66 -0
  112. package/src/mcp/mcp-registry.service.ts +75 -69
  113. package/src/mcp/mcp.module.ts +43 -36
  114. package/src/mcp-chat/mcp-chat.controller.ts +113 -60
  115. package/src/mcp-chat/mcp-chat.service.ts +494 -332
  116. package/src/notification/dto/create-notification.dto.ts +71 -0
  117. package/src/notification/dto/update-progress.dto.ts +16 -0
  118. package/src/notification/notification.controller.ts +71 -0
  119. package/src/notification/notification.module.ts +16 -0
  120. package/src/notification/notification.service.ts +124 -0
  121. package/src/setting/dto/update-setting-list.dto.ts +32 -0
  122. package/src/setting/setting.controller.ts +9 -0
  123. package/src/setting/setting.service.ts +91 -0
  124. package/src/task/task.service.ts +72 -0
  125. package/src/webhook-integration/webhook-integration.service.ts +934 -735
  126. package/src/webhook-integration/webhook-utils.ts +136 -79
@@ -6,25 +6,62 @@ import {
6
6
  PageHeader,
7
7
  PaginationFooter,
8
8
  SearchBar,
9
- StatsCards,
10
9
  } from '@/components/entity-list';
10
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
11
+ import { Badge } from '@/components/ui/badge';
11
12
  import { Button } from '@/components/ui/button';
12
13
  import {
13
14
  Card,
15
+ CardContent,
14
16
  CardDescription,
15
17
  CardHeader,
16
18
  CardTitle,
17
19
  } from '@/components/ui/card';
20
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
21
+ import { Skeleton } from '@/components/ui/skeleton';
22
+ import {
23
+ Table,
24
+ TableBody,
25
+ TableCell,
26
+ TableHead,
27
+ TableHeader,
28
+ TableRow,
29
+ } from '@/components/ui/table';
30
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
31
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
18
32
  import { getPhotoUrl } from '@/lib/get-photo-url';
19
33
  import { getUserEmail } from '@/lib/get-user-email';
20
34
  import { PaginatedResult } from '@/types/pagination-result';
21
35
  import { User, UserMfa } from '@hed-hog/api-types';
22
36
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
23
- import { Mail, ShieldCheck, UserPlus, Users } from 'lucide-react';
24
- import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
37
+ import {
38
+ type ColumnDef,
39
+ flexRender,
40
+ getCoreRowModel,
41
+ useReactTable,
42
+ } from '@tanstack/react-table';
43
+ import {
44
+ LayoutGrid,
45
+ List,
46
+ Mail,
47
+ MoreHorizontal,
48
+ Pencil,
49
+ ShieldCheck,
50
+ UserPlus,
51
+ Users,
52
+ } from 'lucide-react';
25
53
  import { useTranslations } from 'next-intl';
26
- import { useState } from 'react';
54
+ import { useEffect, useMemo, useState } from 'react';
27
55
  import { UserSheet } from './user-sheet';
56
+ import {
57
+ DropdownMenu,
58
+ DropdownMenuContent,
59
+ DropdownMenuItem,
60
+ DropdownMenuTrigger,
61
+ } from '@/components/ui/dropdown-menu';
62
+
63
+ const USER_VIEW_STORAGE_KEY = 'core-user-view-mode';
64
+ type UserViewMode = 'table' | 'cards';
28
65
 
29
66
  type RequestResponse<T> = {
30
67
  paginate: PaginatedResult<T>;
@@ -42,6 +79,7 @@ export default function UserPage() {
42
79
  const [searchQuery, setSearchQuery] = useState('');
43
80
  const [isSheetOpen, setIsSheetOpen] = useState(false);
44
81
  const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
82
+ const [viewMode, setViewMode] = useState<UserViewMode>('table');
45
83
 
46
84
  const [page, setPage] = useState(1);
47
85
  const [pageSize, setPageSize] = usePersistedPageSize({
@@ -51,6 +89,25 @@ export default function UserPage() {
51
89
  });
52
90
  const [statusFilter, setStatusFilter] = useState('all');
53
91
 
92
+ useEffect(() => {
93
+ try {
94
+ const saved = window.localStorage.getItem(USER_VIEW_STORAGE_KEY);
95
+ if (saved === 'table' || saved === 'cards') setViewMode(saved);
96
+ } catch {
97
+ // Ignore storage read failures.
98
+ }
99
+ }, []);
100
+
101
+ const handleViewModeChange = (value: string) => {
102
+ if (value !== 'table' && value !== 'cards') return;
103
+ setViewMode(value);
104
+ try {
105
+ window.localStorage.setItem(USER_VIEW_STORAGE_KEY, value);
106
+ } catch {
107
+ // Ignore storage write failures.
108
+ }
109
+ };
110
+
54
111
  const {
55
112
  data: { paginate, stats } = {
56
113
  paginate: {},
@@ -98,6 +155,137 @@ export default function UserPage() {
98
155
  if (!open) setSelectedUserId(null);
99
156
  };
100
157
 
158
+ const statsCards = useMemo(
159
+ () => [
160
+ {
161
+ key: 'total',
162
+ title: t('totalUsers'),
163
+ value: stats?.total ?? 0,
164
+ icon: Users,
165
+ accentClassName: 'from-purple-500/20 via-purple-400/10 to-transparent',
166
+ iconContainerClassName: 'bg-purple-50 text-purple-600',
167
+ },
168
+ {
169
+ key: 'new7days',
170
+ title: t('totalNewUsers7Days'),
171
+ value: stats?.newLast7Days ?? 0,
172
+ icon: UserPlus,
173
+ accentClassName: 'from-rose-500/20 via-rose-400/10 to-transparent',
174
+ iconContainerClassName: 'bg-rose-50 text-rose-600',
175
+ },
176
+ {
177
+ key: 'validated',
178
+ title: t('totalValidatedUsers'),
179
+ value: 0,
180
+ icon: Mail,
181
+ accentClassName: 'from-green-500/20 via-emerald-400/10 to-transparent',
182
+ iconContainerClassName: 'bg-green-50 text-green-600',
183
+ },
184
+ {
185
+ key: 'blocked',
186
+ title: t('totalBlockedUsers'),
187
+ value: stats?.blocked ?? 0,
188
+ icon: ShieldCheck,
189
+ accentClassName: 'from-amber-500/20 via-orange-400/10 to-transparent',
190
+ iconContainerClassName: 'bg-amber-50 text-amber-600',
191
+ },
192
+ ],
193
+ [stats, t]
194
+ );
195
+
196
+ const columns = useMemo<ColumnDef<User>[]>(
197
+ () => [
198
+ {
199
+ accessorKey: 'name',
200
+ header: t('columnName'),
201
+ cell: ({ row }) => {
202
+ const user = row.original;
203
+ return (
204
+ <div className="flex items-center gap-3">
205
+ <Avatar className="h-8 w-8 rounded-full">
206
+ <AvatarImage
207
+ src={getPhotoUrl(user.photo_id)}
208
+ alt={user.name}
209
+ className="rounded-full object-cover"
210
+ />
211
+ <AvatarFallback className="rounded-full bg-muted text-xs font-semibold uppercase">
212
+ {(user.name ?? '?').slice(0, 2).toUpperCase()}
213
+ </AvatarFallback>
214
+ </Avatar>
215
+ <span className="font-medium">{user.name || '—'}</span>
216
+ </div>
217
+ );
218
+ },
219
+ },
220
+ {
221
+ accessorKey: 'email',
222
+ header: t('columnEmail'),
223
+ cell: ({ row }) => (
224
+ <span className="text-sm text-muted-foreground">
225
+ {getUserEmail(row.original)}
226
+ </span>
227
+ ),
228
+ },
229
+ {
230
+ accessorKey: 'status',
231
+ header: t('columnStatus'),
232
+ cell: ({ row }) => {
233
+ const user = row.original;
234
+ return user.suspended_until ? (
235
+ <Badge
236
+ variant="outline"
237
+ className="border-red-500/20 bg-red-500/10 text-red-600"
238
+ >
239
+ {t('blocked')}
240
+ </Badge>
241
+ ) : (
242
+ <Badge
243
+ variant="outline"
244
+ className="border-green-500/20 bg-green-500/10 text-green-600"
245
+ >
246
+ {t('active')}
247
+ </Badge>
248
+ );
249
+ },
250
+ },
251
+ {
252
+ id: 'actions',
253
+ header: '',
254
+ cell: ({ row }) => (
255
+ <div className="flex justify-end">
256
+ <DropdownMenu>
257
+ <DropdownMenuTrigger asChild>
258
+ <Button variant="ghost" size="icon" className="h-8 w-8">
259
+ <MoreHorizontal className="h-4 w-4" />
260
+ </Button>
261
+ </DropdownMenuTrigger>
262
+ <DropdownMenuContent align="end">
263
+ <DropdownMenuItem onClick={() => handleOpenEdit(row.original)}>
264
+ <Pencil className="mr-2 h-4 w-4" />
265
+ {t('buttonEditUser')}
266
+ </DropdownMenuItem>
267
+ </DropdownMenuContent>
268
+ </DropdownMenu>
269
+ </div>
270
+ ),
271
+ },
272
+ ],
273
+ [t]
274
+ );
275
+
276
+ const tableData = useMemo(
277
+ () => (paginate as PaginatedResult<User>)?.data ?? [],
278
+ [paginate]
279
+ );
280
+
281
+ const table = useReactTable({
282
+ data: tableData,
283
+ columns,
284
+ getCoreRowModel: getCoreRowModel(),
285
+ });
286
+
287
+ const users: User[] = tableData;
288
+
101
289
  return (
102
290
  <Page>
103
291
  <PageHeader
@@ -113,91 +301,94 @@ export default function UserPage() {
113
301
  description={t('description')}
114
302
  />
115
303
 
116
- <StatsCards
117
- stats={[
118
- {
119
- title: t('totalUsers'),
120
- value: String(stats?.total || 0),
121
- icon: <Users className="h-5 w-5" />,
122
- iconBgColor: 'bg-purple-50',
123
- iconColor: 'text-purple-600',
124
- },
125
- {
126
- title: t('totalNewUsers7Days'),
127
- value: String(stats?.newLast7Days || 0),
128
- icon: <UserPlus className="h-5 w-5" />,
129
- iconBgColor: 'bg-rose-50',
130
- iconColor: 'text-rose-600',
131
- },
132
- {
133
- title: t('totalValidatedUsers'),
134
- value: '0',
135
- icon: <Mail className="h-5 w-5" />,
136
- iconBgColor: 'bg-green-50',
137
- iconColor: 'text-green-600',
138
- },
139
- {
140
- title: t('totalBlockedUsers'),
141
- value: String(stats?.blocked || 0),
142
- icon: <ShieldCheck className="h-5 w-5" />,
143
- iconBgColor: 'bg-amber-50',
144
- iconColor: 'text-amber-600',
145
- },
146
- ]}
147
- />
304
+ <KpiCardsGrid items={statsCards} />
148
305
 
149
- <SearchBar
150
- searchQuery={searchQuery}
151
- onSearchChange={setSearchQuery}
152
- onSearch={() => refetch()}
153
- placeholder={t('searchPlaceholder')}
154
- filters={{
155
- value: statusFilter,
156
- onChange: (value) => {
157
- setStatusFilter(value);
158
- setPage(1);
159
- },
160
- placeholder: t('filterPlaceholder'),
161
- options: [
162
- { label: t('filterOptionAll'), value: 'all' },
163
- { label: t('filterOptionNew'), value: 'new' },
164
- { label: t('filterOptionBlocked'), value: 'blocked' },
165
- ],
166
- }}
167
- />
306
+ <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
307
+ <div className="flex-1">
308
+ <SearchBar
309
+ searchQuery={searchQuery}
310
+ onSearchChange={(v) => {
311
+ setSearchQuery(v);
312
+ setPage(1);
313
+ }}
314
+ onSearch={() => refetch()}
315
+ placeholder={t('searchPlaceholder')}
316
+ filters={{
317
+ value: statusFilter,
318
+ onChange: (value) => {
319
+ setStatusFilter(value);
320
+ setPage(1);
321
+ },
322
+ placeholder: t('filterPlaceholder'),
323
+ options: [
324
+ { label: t('filterOptionAll'), value: 'all' },
325
+ { label: t('filterOptionNew'), value: 'new' },
326
+ { label: t('filterOptionBlocked'), value: 'blocked' },
327
+ ],
328
+ }}
329
+ />
330
+ </div>
168
331
 
169
- <div className="flex-1 ">
170
- {isLoading && (
171
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
172
- {Array.from({ length: 3 }).map((_, i) => (
173
- <Card
174
- key={`skeleton-${i}`}
175
- className="flex flex-col justify-between gap-2 rounded-2xl border border-border/60 bg-card p-4 shadow-sm animate-pulse"
176
- >
177
- <CardHeader className="flex items-start justify-between gap-4 p-0">
178
- <div className="flex items-center gap-3">
179
- <div className="h-12 w-12 shrink-0 rounded-full bg-muted" />
180
- <div className="space-y-2 py-1">
181
- <div className="h-4 w-40 rounded bg-muted" />
182
- <div className="h-3 w-32 rounded bg-muted" />
183
- </div>
184
- </div>
185
- <div className="flex flex-col items-end gap-2">
186
- <div className="h-8 w-20 rounded bg-muted" />
187
- <div className="mt-1 h-5 w-20 rounded bg-muted" />
188
- </div>
189
- </CardHeader>
190
- <div className="mt-3 space-y-2 text-sm">
191
- <div className="h-3 w-full rounded bg-muted" />
192
- <div className="h-3 w-full rounded bg-muted" />
193
- <div className="h-3 w-1/2 rounded bg-muted" />
194
- </div>
195
- </Card>
196
- ))}
197
- </div>
198
- )}
332
+ <div className="flex items-center gap-3 xl:justify-end">
333
+ <span className="text-xs font-medium text-muted-foreground">
334
+ {t('viewMode')}
335
+ </span>
336
+ <ToggleGroup
337
+ type="single"
338
+ value={viewMode}
339
+ onValueChange={handleViewModeChange}
340
+ variant="outline"
341
+ size="sm"
342
+ aria-label={t('viewMode')}
343
+ >
344
+ <ToggleGroupItem
345
+ value="table"
346
+ className="gap-1.5 px-2.5"
347
+ aria-label={t('viewModeTable')}
348
+ >
349
+ <List className="h-4 w-4" />
350
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
351
+ </ToggleGroupItem>
352
+ <ToggleGroupItem
353
+ value="cards"
354
+ className="gap-1.5 px-2.5"
355
+ aria-label={t('viewModeCards')}
356
+ >
357
+ <LayoutGrid className="h-4 w-4" />
358
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
359
+ </ToggleGroupItem>
360
+ </ToggleGroup>
361
+ </div>
362
+ </div>
199
363
 
200
- {(paginate?.data?.length || 0) === 0 ? (
364
+ <div className="flex-1">
365
+ {isLoading ? (
366
+ viewMode === 'cards' ? (
367
+ <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
368
+ {Array.from({ length: 6 }).map((_, i) => (
369
+ <Card key={i} className="overflow-hidden py-0">
370
+ <CardContent className="space-y-3 p-4">
371
+ <div className="flex items-center gap-3">
372
+ <Skeleton className="h-12 w-12 rounded-full" />
373
+ <div className="flex-1 space-y-2">
374
+ <Skeleton className="h-4 w-40" />
375
+ <Skeleton className="h-3 w-32" />
376
+ </div>
377
+ </div>
378
+ <Skeleton className="h-3 w-full" />
379
+ <Skeleton className="h-3 w-1/2" />
380
+ </CardContent>
381
+ </Card>
382
+ ))}
383
+ </div>
384
+ ) : (
385
+ <div className="space-y-3">
386
+ {Array.from({ length: 5 }).map((_, i) => (
387
+ <Skeleton key={i} className="h-14 w-full" />
388
+ ))}
389
+ </div>
390
+ )
391
+ ) : users.length === 0 ? (
201
392
  <EmptyState
202
393
  icon={<Users className="h-12 w-12" />}
203
394
  title={t('noUsersFound')}
@@ -205,141 +396,192 @@ export default function UserPage() {
205
396
  actionLabel={t('buttonAddUser')}
206
397
  onAction={handleOpenCreate}
207
398
  />
399
+ ) : viewMode === 'table' ? (
400
+ <div className="overflow-x-auto">
401
+ <Table>
402
+ <TableHeader>
403
+ {table.getHeaderGroups().map((headerGroup) => (
404
+ <TableRow key={headerGroup.id}>
405
+ {headerGroup.headers.map((header) => (
406
+ <TableHead key={header.id}>
407
+ {header.isPlaceholder
408
+ ? null
409
+ : flexRender(
410
+ header.column.columnDef.header,
411
+ header.getContext()
412
+ )}
413
+ </TableHead>
414
+ ))}
415
+ </TableRow>
416
+ ))}
417
+ </TableHeader>
418
+ <TableBody>
419
+ {table.getRowModel().rows.map((row) => (
420
+ <TableRow
421
+ key={row.id}
422
+ className="cursor-pointer"
423
+ onDoubleClick={() => handleOpenEdit(row.original)}
424
+ >
425
+ {row.getVisibleCells().map((cell) => (
426
+ <TableCell key={cell.id}>
427
+ {flexRender(
428
+ cell.column.columnDef.cell,
429
+ cell.getContext()
430
+ )}
431
+ </TableCell>
432
+ ))}
433
+ </TableRow>
434
+ ))}
435
+ </TableBody>
436
+ </Table>
437
+ </div>
208
438
  ) : (
209
- <div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
210
- {paginate?.data?.map((user: User) => (
439
+ <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3">
440
+ {users.map((user: User) => (
211
441
  <Card
212
442
  key={user.id}
213
443
  onDoubleClick={() => handleOpenEdit(user)}
214
- 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"
444
+ className="cursor-pointer overflow-hidden border-border/70 py-0 transition-colors hover:border-border hover:shadow-md"
215
445
  >
216
- <CardHeader className="flex items-start justify-between gap-4 p-0">
217
- <div className="flex items-center gap-3">
218
- <div className="h-12 w-12 shrink-0 rounded-full bg-muted">
219
- <img
220
- src={getPhotoUrl(user.photo_id)}
221
- alt={user.name}
222
- className="h-12 w-12 rounded-full object-cover"
223
- />
446
+ <CardContent className="flex flex-col gap-3 p-4">
447
+ <CardHeader className="flex items-start justify-between gap-4 p-0">
448
+ <div className="flex items-center gap-3">
449
+ <Avatar className="h-12 w-12 shrink-0 rounded-full">
450
+ <AvatarImage
451
+ src={getPhotoUrl(user.photo_id)}
452
+ alt={user.name}
453
+ className="rounded-full object-cover"
454
+ />
455
+ <AvatarFallback className="rounded-full bg-muted text-sm font-semibold uppercase">
456
+ {(user.name ?? '?').slice(0, 2).toUpperCase()}
457
+ </AvatarFallback>
458
+ </Avatar>
459
+ <div>
460
+ <CardTitle className="text-sm font-semibold">
461
+ {user.name || '—'}
462
+ </CardTitle>
463
+ <CardDescription className="text-xs text-muted-foreground">
464
+ {getUserEmail(user)}
465
+ </CardDescription>
466
+ </div>
224
467
  </div>
225
- <div>
226
- <CardTitle className="text-sm font-semibold">
227
- {user.name || '—'}
228
- </CardTitle>
229
- <CardDescription className="text-xs text-muted-foreground">
230
- {getUserEmail(user)}
231
- </CardDescription>
468
+ <div className="flex flex-col items-end gap-2">
469
+ <Button
470
+ variant="outline"
471
+ size="sm"
472
+ onClick={() => handleOpenEdit(user)}
473
+ >
474
+ {t('buttonEditUser')}
475
+ </Button>
476
+ <div className="mt-1">
477
+ {user.suspended_until ? (
478
+ <Badge
479
+ variant="outline"
480
+ className="border-red-500/20 bg-red-500/10 text-red-600"
481
+ >
482
+ <ShieldCheck className="mr-1 h-3 w-3" />
483
+ {t('blocked')}
484
+ </Badge>
485
+ ) : (
486
+ <Badge
487
+ variant="outline"
488
+ className="border-green-500/20 bg-green-500/10 text-green-600"
489
+ >
490
+ <Users className="mr-1 h-3 w-3" />
491
+ {t('active')}
492
+ </Badge>
493
+ )}
494
+ </div>
232
495
  </div>
233
- </div>
234
- <div className="flex flex-col items-end gap-2">
235
- <Button
236
- variant="outline"
237
- size="sm"
238
- onClick={() => handleOpenEdit(user)}
239
- >
240
- {t('buttonEditUser')}
241
- </Button>
496
+ </CardHeader>
242
497
 
243
- <div className="mt-1">
244
- {user.suspended_until ? (
245
- <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">
246
- <ShieldCheck className="h-3 w-3" />
247
- {t('blocked')}
248
- </span>
498
+ <div className="space-y-2 text-sm text-muted-foreground">
499
+ <div className="flex flex-wrap items-center gap-2">
500
+ {(user as any).user_account?.includes('google') && (
501
+ <div
502
+ title={t('googleConnected')}
503
+ className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
504
+ >
505
+ <svg
506
+ className="h-4 w-4"
507
+ viewBox="0 0 24 24"
508
+ fill="currentColor"
509
+ aria-hidden
510
+ />
511
+ {t('google')}
512
+ </div>
513
+ )}
514
+ {(user as any).connectedAccounts?.includes('github') && (
515
+ <div
516
+ title={t('githubConnected')}
517
+ className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
518
+ >
519
+ <svg
520
+ className="h-4 w-4"
521
+ viewBox="0 0 24 24"
522
+ fill="currentColor"
523
+ aria-hidden
524
+ />
525
+ {t('github')}
526
+ </div>
527
+ )}
528
+ {(user as any).connectedAccounts?.includes(
529
+ 'facebook'
530
+ ) && (
531
+ <div
532
+ title={t('facebookConnected')}
533
+ className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
534
+ >
535
+ <svg
536
+ className="h-4 w-4"
537
+ viewBox="0 0 24 24"
538
+ fill="currentColor"
539
+ aria-hidden
540
+ />
541
+ {t('facebook')}
542
+ </div>
543
+ )}
544
+ {(!(user as any).connectedAccounts ||
545
+ (user as any).connectedAccounts.length === 0) && (
546
+ <div className="text-xs text-muted-foreground">
547
+ {t('noConnectedAccounts')}
548
+ </div>
549
+ )}
550
+ </div>
551
+ <div className="flex flex-wrap items-center gap-2">
552
+ {user.user_mfa && user.user_mfa.length > 0 ? (
553
+ user.user_mfa.map((mfa: UserMfa) => (
554
+ <span
555
+ key={String(mfa.id)}
556
+ className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
557
+ >
558
+ {mfa.name}
559
+ </span>
560
+ ))
249
561
  ) : (
250
- <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">
251
- <Users className="h-3 w-3" />
252
- {t('active')}
562
+ <span className="text-xs text-muted-foreground">
563
+ {t('noMfaMethods')}
253
564
  </span>
254
565
  )}
255
566
  </div>
256
- </div>
257
- </CardHeader>
258
- <div className="mt-3 space-y-2 text-sm text-muted-foreground">
259
- <div className="flex flex-wrap items-center gap-2">
260
- {(user as any).user_account?.includes('google') && (
261
- <div
262
- title={t('googleConnected')}
263
- className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
264
- >
265
- <svg
266
- className="h-4 w-4"
267
- viewBox="0 0 24 24"
268
- fill="currentColor"
269
- aria-hidden
270
- />
271
- {t('google')}
272
- </div>
273
- )}
274
- {(user as any).connectedAccounts?.includes('github') && (
275
- <div
276
- title={t('githubConnected')}
277
- className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
278
- >
279
- <svg
280
- className="h-4 w-4"
281
- viewBox="0 0 24 24"
282
- fill="currentColor"
283
- aria-hidden
284
- />
285
- {t('github')}
286
- </div>
287
- )}
288
- {(user as any).connectedAccounts?.includes('facebook') && (
289
- <div
290
- title={t('facebookConnected')}
291
- className="inline-flex items-center gap-2 rounded-md bg-slate-50 px-2 py-1 text-xs text-slate-700"
292
- >
293
- <svg
294
- className="h-4 w-4"
295
- viewBox="0 0 24 24"
296
- fill="currentColor"
297
- aria-hidden
298
- />
299
- {t('facebook')}
567
+ {user.suspended_reason && (
568
+ <div className="text-xs text-muted-foreground">
569
+ <span className="font-medium text-foreground">
570
+ {t('reason')}:
571
+ </span>{' '}
572
+ {user.suspended_reason}
300
573
  </div>
301
574
  )}
302
- {(!(user as any).connectedAccounts ||
303
- (user as any).connectedAccounts.length === 0) && (
575
+ {user.suspended_until && (
304
576
  <div className="text-xs text-muted-foreground">
305
- {t('noConnectedAccounts')}
577
+ <span className="font-medium text-foreground">
578
+ {t('until')}:
579
+ </span>{' '}
580
+ {user.suspended_until}
306
581
  </div>
307
582
  )}
308
583
  </div>
309
- <div className="flex flex-wrap items-center gap-2">
310
- {user.user_mfa && user.user_mfa.length > 0 ? (
311
- user.user_mfa.map((mfa: UserMfa) => (
312
- <span
313
- key={String(mfa.id)}
314
- className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
315
- title={`MFA: ${mfa}`}
316
- >
317
- {mfa.name}
318
- </span>
319
- ))
320
- ) : (
321
- <span className="text-xs text-muted-foreground">
322
- {t('noMfaMethods')}
323
- </span>
324
- )}
325
- </div>
326
- {user.suspended_reason && (
327
- <div className="text-xs text-muted-foreground">
328
- <span className="font-medium text-foreground">
329
- {t('reason')}:
330
- </span>{' '}
331
- {user.suspended_reason}
332
- </div>
333
- )}
334
- {user.suspended_until && (
335
- <div className="text-xs text-muted-foreground">
336
- <span className="font-medium text-foreground">
337
- {t('until')}:
338
- </span>{' '}
339
- {user.suspended_until}
340
- </div>
341
- )}
342
- </div>
584
+ </CardContent>
343
585
  </Card>
344
586
  ))}
345
587
  </div>