@allbluecn/admin-app 0.2.3 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/admin-app",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "class-variance-authority": "^0.7.1",
31
31
  "clsx": "^2.1.1",
32
32
  "date-fns": "^4.4.0",
33
- "lucide-react": "^1.27.0",
33
+ "lucide-react": "^1.43.0",
34
34
  "motion": "^12.42.2",
35
35
  "next-themes": "^0.4.6",
36
36
  "radix-ui": "^1.4.3",
@@ -42,9 +42,9 @@
42
42
  "start-authjs": "^1.0.2",
43
43
  "tailwind-merge": "^3.5.0",
44
44
  "zod": "^4.4.3",
45
- "@allbluecn/database": "0.5.0",
46
- "@allbluecn/kernel": "0.6.0",
47
- "@allbluecn/web-app": "0.10.3"
45
+ "@allbluecn/database": "0.8.0",
46
+ "@allbluecn/web-app": "0.11.1",
47
+ "@allbluecn/kernel": "0.8.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@cloudflare/vite-plugin": "^1.40.1",
@@ -54,19 +54,22 @@
54
54
  "@tailwindcss/vite": "^4.3.2",
55
55
  "@tanstack/react-query-devtools": "5.102.8",
56
56
  "@tanstack/react-router-devtools": "1.167.1",
57
+ "@testing-library/jest-dom": "7.0.1",
58
+ "@testing-library/react": "^16.3.2",
57
59
  "@types/bcryptjs": "^2.4.6",
58
60
  "@types/node": "^24.13.3",
59
61
  "@types/react": "^19",
60
62
  "@types/react-dom": "^19",
61
63
  "@vitejs/plugin-react": "^6.0.1",
62
- "@vitest/coverage-v8": "^4.1.10",
64
+ "@vitest/coverage-v8": "5.0.0",
63
65
  "eslint": "^9",
66
+ "jsdom": "^29.1.1",
64
67
  "nitro": "3.0.260903-beta",
65
68
  "shadcn": "^4.15.0",
66
69
  "tailwindcss": "^4",
67
70
  "typescript": "^7",
68
71
  "vite": "^8.1.5",
69
- "vitest": "^4.1.10",
72
+ "vitest": "5.0.0",
70
73
  "wrangler": "^4.99.0"
71
74
  },
72
75
  "files": [
@@ -81,6 +84,8 @@
81
84
  "build": "node scripts/i18n/compile.mjs && vite build && if [ -d .output/server ]; then cp instrument.server.mjs .output/server; fi",
82
85
  "start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs",
83
86
  "lint": "eslint",
84
- "typecheck": "node scripts/i18n/compile.mjs && tsc --noEmit"
87
+ "typecheck": "node scripts/i18n/compile.mjs && tsc --noEmit",
88
+ "test": "vitest",
89
+ "test:run": "vitest run"
85
90
  }
86
91
  }
@@ -0,0 +1,59 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi } from 'vitest'
19
+ import { render } from '@testing-library/react'
20
+
21
+ // @lobehub/icons 是数百个品牌图标的聚合包,dynamic import 会拖慢 vitest 且
22
+ // 测试目标是 fallback 渲染路径;mock 为空对象使 mod[lobeName] 为 undefined,
23
+ // ProviderLogo 走 InitialFallback 分支(与"平台下架的 provider"真实行为一致)。
24
+ vi.mock('@lobehub/icons', () => ({}))
25
+
26
+ import { ProviderIcon } from '../../components/ai-provider/provider-icon'
27
+
28
+ describe('ProviderIcon', () => {
29
+ it('按 size 设置容器内联宽高', () => {
30
+ const { container } = render(
31
+ <ProviderIcon
32
+ provider={{ providerId: 'deprecated-vendor', brandName: 'Deprecated Vendor' }}
33
+ size={56}
34
+ />
35
+ )
36
+ const box = container.querySelector('div')
37
+ expect(box).not.toBeNull()
38
+ expect(box).toHaveStyle({ width: '56px', height: '56px' })
39
+ })
40
+
41
+ it('未知 providerId 时回落到品牌名首字母', () => {
42
+ const { container } = render(
43
+ <ProviderIcon
44
+ provider={{ providerId: 'deprecated-vendor', brandName: 'Deprecated Vendor' }}
45
+ />
46
+ )
47
+ expect(container.textContent).toContain('D')
48
+ })
49
+
50
+ it('透传 className 到容器', () => {
51
+ const { container } = render(
52
+ <ProviderIcon
53
+ provider={{ providerId: 'deprecated-vendor', brandName: 'Deprecated Vendor' }}
54
+ className="mt-4"
55
+ />
56
+ )
57
+ expect(container.querySelector('div')).toHaveClass('mt-4')
58
+ })
59
+ })
@@ -0,0 +1,35 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ /// <reference types="vitest/globals" />
19
+ import '@testing-library/jest-dom/vitest'
20
+
21
+ import { overwriteGetLocale } from '@/paraglide/runtime.js'
22
+
23
+ // jsdom 无 ResizeObserver(@tanstack/charts、虚拟滚动等库模块加载时引用)
24
+ if (typeof globalThis.ResizeObserver === 'undefined') {
25
+ globalThis.ResizeObserver = class ResizeObserver {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ } as unknown as typeof globalThis.ResizeObserver
30
+ }
31
+
32
+ beforeAll(() => {
33
+ // jsdom 的 navigator.language 为 en-US,会导致中文文案断言失败
34
+ overwriteGetLocale(() => 'zh')
35
+ })
@@ -0,0 +1,527 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { useState } from "react"
19
+ import type React from "react"
20
+ import { m } from "@/paraglide/messages.js"
21
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
22
+ import { toast } from "sonner"
23
+ import { Ticket, Plus, Copy, Ban, CheckCircle2, Trash2, Users } from "lucide-react"
24
+ import { PageHeader } from "@/components/common/page-header"
25
+ import { Button } from "@/components/ui/button"
26
+ import { Badge } from "@/components/ui/badge"
27
+ import { Input } from "@/components/ui/input"
28
+ import { Label } from "@/components/ui/label"
29
+ import { Switch } from "@/components/ui/switch"
30
+ import { Skeleton } from "@/components/ui/skeleton"
31
+ import {
32
+ Dialog,
33
+ DialogContent,
34
+ DialogDescription,
35
+ DialogFooter,
36
+ DialogHeader,
37
+ DialogTitle,
38
+ } from "@/components/ui/dialog"
39
+ import {
40
+ AlertDialog,
41
+ AlertDialogAction,
42
+ AlertDialogCancel,
43
+ AlertDialogContent,
44
+ AlertDialogDescription,
45
+ AlertDialogFooter,
46
+ AlertDialogHeader,
47
+ AlertDialogTitle,
48
+ } from "@/components/ui/alert-dialog"
49
+ import {
50
+ Table,
51
+ TableBody,
52
+ TableCell,
53
+ TableHead,
54
+ TableHeader,
55
+ TableRow,
56
+ } from "@/components/ui/table"
57
+ import {
58
+ Tooltip,
59
+ TooltipContent,
60
+ TooltipTrigger,
61
+ } from "@/components/ui/tooltip"
62
+ import { Card, CardContent } from "@/components/ui/card"
63
+ import {
64
+ listInvitationCodesFn,
65
+ createInvitationCodeFn,
66
+ toggleInvitationCodeFn,
67
+ deleteInvitationCodeFn,
68
+ getRegistrationModeFn,
69
+ setRegistrationModeFn,
70
+ } from "@/server/serverFns/invitations"
71
+ import { formatTimestamp } from "@allbluecn/web-app/lib/datetime"
72
+
73
+ type RegistrationMode = "invite" | "open"
74
+
75
+ type InvitationUseRow = {
76
+ userId: string
77
+ usedAt: Date | string
78
+ user: { username: string }
79
+ }
80
+
81
+ type InvitationCodeRow = {
82
+ id: string
83
+ code: string
84
+ maxUses: number
85
+ usedCount: number
86
+ expiresAt: Date | string
87
+ disabledAt: Date | string | null
88
+ createdAt: Date | string
89
+ creator: string | null
90
+ uses: InvitationUseRow[]
91
+ }
92
+
93
+ type CodeStatus = "disabled" | "expired" | "exhausted" | "active"
94
+
95
+ function statusOf(row: InvitationCodeRow): CodeStatus {
96
+ if (row.disabledAt != null) return "disabled"
97
+ if (new Date(row.expiresAt).getTime() < Date.now()) return "expired"
98
+ if (row.usedCount >= row.maxUses) return "exhausted"
99
+ return "active"
100
+ }
101
+
102
+ function StatusBadge({ status }: { status: CodeStatus }) {
103
+ const config: Record<CodeStatus, { variant: React.ComponentProps<typeof Badge>["variant"]; label: string; className?: string }> = {
104
+ active: { variant: "default", label: m["admin-invitations_status_active"]() },
105
+ exhausted: { variant: "secondary", label: m["admin-invitations_status_exhausted"]() },
106
+ expired: { variant: "outline", label: m["admin-invitations_status_expired"]() },
107
+ disabled: { variant: "secondary", label: m["admin-invitations_status_disabled"](), className: "text-muted-foreground" },
108
+ }
109
+ const c = config[status]
110
+ return <Badge variant={c.variant} className={c.className}>{c.label}</Badge>
111
+ }
112
+
113
+ export function InvitationsPage(): React.ReactElement {
114
+ const queryClient = useQueryClient()
115
+ const [pendingDelete, setPendingDelete] = useState<InvitationCodeRow | null>(null)
116
+ const [usesViewer, setUsesViewer] = useState<InvitationCodeRow | null>(null)
117
+ const [createOpen, setCreateOpen] = useState(false)
118
+ const [createForm, setCreateForm] = useState({ code: "", maxUses: 1, validDays: 30 })
119
+
120
+ const codesQuery = useQuery({
121
+ queryKey: ["invitations", "codes"],
122
+ queryFn: () => listInvitationCodesFn({ data: { page: 1, pageSize: 100 } }),
123
+ })
124
+ const modeQuery = useQuery({
125
+ queryKey: ["invitations", "mode"],
126
+ queryFn: () => getRegistrationModeFn({ data: {} }),
127
+ })
128
+
129
+ const invalidateCodes = () =>
130
+ queryClient.invalidateQueries({ queryKey: ["invitations", "codes"] })
131
+
132
+ const modeMutation = useMutation({
133
+ mutationFn: (mode: RegistrationMode) => setRegistrationModeFn({ data: { mode } }),
134
+ onSuccess: () => {
135
+ toast.success(m["admin-invitations_toast_modeUpdated"]())
136
+ queryClient.invalidateQueries({ queryKey: ["invitations", "mode"] })
137
+ },
138
+ onError: (err) =>
139
+ toast.error(err instanceof Error ? err.message : m["admin-invitations_toast_modeFailed"]()),
140
+ })
141
+
142
+ const createMutation = useMutation({
143
+ mutationFn: (vars: { code?: string; maxUses: number; validDays: number }) =>
144
+ createInvitationCodeFn({ data: vars }),
145
+ onSuccess: () => {
146
+ toast.success(m["admin-invitations_toast_created"]())
147
+ setCreateOpen(false)
148
+ setCreateForm({ code: "", maxUses: 1, validDays: 30 })
149
+ invalidateCodes()
150
+ },
151
+ onError: (err) =>
152
+ toast.error(err instanceof Error ? err.message : m["admin-invitations_toast_createFailed"]()),
153
+ })
154
+
155
+ const toggleMutation = useMutation({
156
+ mutationFn: (id: string) => toggleInvitationCodeFn({ data: { id } }),
157
+ onSuccess: () => {
158
+ toast.success(m["admin-invitations_toast_toggled"]())
159
+ invalidateCodes()
160
+ },
161
+ onError: (err) =>
162
+ toast.error(err instanceof Error ? err.message : m["admin-invitations_toast_toggleFailed"]()),
163
+ })
164
+
165
+ const deleteMutation = useMutation({
166
+ mutationFn: (id: string) => deleteInvitationCodeFn({ data: { id } }),
167
+ onSuccess: () => {
168
+ toast.success(m["admin-invitations_toast_deleted"]())
169
+ setPendingDelete(null)
170
+ invalidateCodes()
171
+ },
172
+ onError: (err) => {
173
+ toast.error(err instanceof Error ? err.message : m["admin-invitations_toast_deleteFailed"]())
174
+ setPendingDelete(null)
175
+ },
176
+ })
177
+
178
+ const codes = codesQuery.data?.codes ?? []
179
+ const loading = codesQuery.isLoading
180
+ const mode = modeQuery.data?.mode ?? "invite"
181
+
182
+ const handleCopy = async (code: string) => {
183
+ try {
184
+ await navigator.clipboard.writeText(code)
185
+ toast.success(m["admin-invitations_toast_copied"]())
186
+ } catch {
187
+ toast.error(m["admin-invitations_toast_copyFailed"]())
188
+ }
189
+ }
190
+
191
+ return (
192
+ <div className="space-y-6 p-6">
193
+ <PageHeader
194
+ title={m["admin-invitations_page_title"]()}
195
+ description={m["admin-invitations_page_subtitle"]()}
196
+ icon={Ticket}
197
+ />
198
+
199
+ <Card>
200
+ <CardContent className="pt-6">
201
+ <div className="flex items-center justify-between gap-6">
202
+ <div>
203
+ <div className="text-sm font-medium">{m["admin-invitations_mode_title"]()}</div>
204
+ <div className="text-xs text-muted-foreground mt-0.5">
205
+ {mode === "open"
206
+ ? m["admin-invitations_mode_open_desc"]()
207
+ : m["admin-invitations_mode_invite_desc"]()}
208
+ </div>
209
+ </div>
210
+ <div className="flex items-center gap-3">
211
+ <Switch
212
+ checked={mode === "open"}
213
+ onCheckedChange={(checked) =>
214
+ modeMutation.mutate(checked ? "open" : "invite")
215
+ }
216
+ disabled={modeMutation.isPending}
217
+ aria-label={m["admin-invitations_mode_title"]()}
218
+ />
219
+ <span className="text-sm font-medium">
220
+ {mode === "open"
221
+ ? m["admin-invitations_mode_open"]()
222
+ : m["admin-invitations_mode_invite"]()}
223
+ </span>
224
+ </div>
225
+ </div>
226
+ </CardContent>
227
+ </Card>
228
+
229
+ <div className="space-y-3">
230
+ <div className="flex items-center justify-between">
231
+ <h2 className="text-lg font-semibold">{m["admin-invitations_page_title"]()}</h2>
232
+ <Button onClick={() => setCreateOpen(true)}>
233
+ <Plus className="size-4" />
234
+ {m["admin-invitations_create_title"]()}
235
+ </Button>
236
+ </div>
237
+
238
+ {loading ? (
239
+ <div className="space-y-2">
240
+ <Skeleton className="h-10 w-full" />
241
+ <Skeleton className="h-10 w-full" />
242
+ <Skeleton className="h-10 w-full" />
243
+ </div>
244
+ ) : codes.length === 0 ? (
245
+ <div className="rounded-lg border border-dashed py-12 text-center text-muted-foreground">
246
+ {m["admin-invitations_empty"]()}
247
+ </div>
248
+ ) : (
249
+ <div className="rounded-lg border border-border overflow-hidden">
250
+ <Table>
251
+ <TableHeader>
252
+ <TableRow className="bg-muted/50">
253
+ <TableHead>{m["admin-invitations_table_columns_code"]()}</TableHead>
254
+ <TableHead>{m["admin-invitations_table_columns_status"]()}</TableHead>
255
+ <TableHead>{m["admin-invitations_table_columns_uses"]()}</TableHead>
256
+ <TableHead>{m["admin-invitations_table_columns_expires"]()}</TableHead>
257
+ <TableHead>{m["admin-invitations_table_columns_creator"]()}</TableHead>
258
+ <TableHead className="text-right">
259
+ {m["admin-invitations_table_columns_actions"]()}
260
+ </TableHead>
261
+ </TableRow>
262
+ </TableHeader>
263
+ <TableBody>
264
+ {codes.map((row) => {
265
+ const status = statusOf(row)
266
+ const canDelete = row.usedCount === 0
267
+ return (
268
+ <TableRow key={row.id}>
269
+ <TableCell>
270
+ <button
271
+ type="button"
272
+ className="inline-flex items-center gap-1.5 font-mono text-sm hover:text-primary"
273
+ onClick={() => handleCopy(row.code)}
274
+ title={m["admin-invitations_toast_copied"]()}
275
+ >
276
+ {row.code}
277
+ <Copy className="size-3.5 text-muted-foreground" />
278
+ </button>
279
+ </TableCell>
280
+ <TableCell>
281
+ <StatusBadge status={status} />
282
+ </TableCell>
283
+ <TableCell>
284
+ <div className="flex items-center gap-1.5">
285
+ <span className="text-sm">
286
+ {row.usedCount}/{row.maxUses}
287
+ </span>
288
+ {row.uses.length > 0 && (
289
+ <Button
290
+ variant="ghost"
291
+ size="sm"
292
+ className="h-7 px-2"
293
+ onClick={() => setUsesViewer(row)}
294
+ >
295
+ <Users className="size-3.5" />
296
+ </Button>
297
+ )}
298
+ </div>
299
+ </TableCell>
300
+ <TableCell>
301
+ <span className="text-sm">
302
+ {formatTimestamp(row.expiresAt)}
303
+ </span>
304
+ </TableCell>
305
+ <TableCell>
306
+ {row.creator ? (
307
+ <span className="text-sm">{row.creator}</span>
308
+ ) : (
309
+ <span className="text-sm text-muted-foreground">—</span>
310
+ )}
311
+ </TableCell>
312
+ <TableCell className="text-right">
313
+ <div className="inline-flex items-center gap-1">
314
+ <Button
315
+ variant="ghost"
316
+ size="sm"
317
+ className={row.disabledAt != null ? "h-8" : "h-8"}
318
+ disabled={toggleMutation.isPending}
319
+ onClick={() => toggleMutation.mutate(row.id)}
320
+ >
321
+ {row.disabledAt != null ? (
322
+ <>
323
+ <CheckCircle2 className="size-4" />
324
+ {m["admin-invitations_action_toggle_enable"]()}
325
+ </>
326
+ ) : (
327
+ <>
328
+ <Ban className="size-4" />
329
+ {m["admin-invitations_action_toggle_disable"]()}
330
+ </>
331
+ )}
332
+ </Button>
333
+ {canDelete ? (
334
+ <Button
335
+ variant="ghost"
336
+ size="sm"
337
+ className="h-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
338
+ onClick={() => setPendingDelete(row)}
339
+ >
340
+ <Trash2 className="size-4" />
341
+ {m["admin-invitations_action_delete"]()}
342
+ </Button>
343
+ ) : (
344
+ <Tooltip>
345
+ <TooltipTrigger asChild>
346
+ <Button
347
+ variant="ghost"
348
+ size="sm"
349
+ className="h-8 text-destructive"
350
+ disabled
351
+ >
352
+ <Trash2 className="size-4" />
353
+ </Button>
354
+ </TooltipTrigger>
355
+ <TooltipContent>
356
+ {m["admin-invitations_delete_disabled_hint"]()}
357
+ </TooltipContent>
358
+ </Tooltip>
359
+ )}
360
+ </div>
361
+ </TableCell>
362
+ </TableRow>
363
+ )
364
+ })}
365
+ </TableBody>
366
+ </Table>
367
+ </div>
368
+ )}
369
+ </div>
370
+
371
+ <Dialog open={createOpen} onOpenChange={setCreateOpen}>
372
+ <DialogContent>
373
+ <DialogHeader>
374
+ <DialogTitle>{m["admin-invitations_create_title"]()}</DialogTitle>
375
+ <DialogDescription>
376
+ {m["admin-invitations_create_desc"]()}
377
+ </DialogDescription>
378
+ </DialogHeader>
379
+ <div className="space-y-4">
380
+ <div className="space-y-2">
381
+ <Label htmlFor="invite-code">{m["admin-invitations_create_code_label"]()}</Label>
382
+ <Input
383
+ id="invite-code"
384
+ value={createForm.code}
385
+ onChange={(e) =>
386
+ setCreateForm((f) => ({ ...f, code: e.target.value }))
387
+ }
388
+ placeholder={m["admin-invitations_create_code_placeholder"]()}
389
+ maxLength={32}
390
+ pattern="[A-Za-z0-9_-]*"
391
+ />
392
+ <p className="text-xs text-muted-foreground">
393
+ {m["admin-invitations_create_code_help"]()}
394
+ </p>
395
+ </div>
396
+ <div className="grid grid-cols-2 gap-4">
397
+ <div className="space-y-2">
398
+ <Label htmlFor="invite-maxuses">
399
+ {m["admin-invitations_create_maxuses_label"]()}
400
+ </Label>
401
+ <Input
402
+ id="invite-maxuses"
403
+ type="number"
404
+ min={1}
405
+ max={9999}
406
+ value={createForm.maxUses}
407
+ onChange={(e) =>
408
+ setCreateForm((f) => ({
409
+ ...f,
410
+ maxUses: Math.max(1, Math.min(9999, Number(e.target.value) || 1)),
411
+ }))
412
+ }
413
+ />
414
+ </div>
415
+ <div className="space-y-2">
416
+ <Label htmlFor="invite-validdays">
417
+ {m["admin-invitations_create_validdays_label"]()}
418
+ </Label>
419
+ <Input
420
+ id="invite-validdays"
421
+ type="number"
422
+ min={1}
423
+ max={365}
424
+ value={createForm.validDays}
425
+ onChange={(e) =>
426
+ setCreateForm((f) => ({
427
+ ...f,
428
+ validDays: Math.max(1, Math.min(365, Number(e.target.value) || 1)),
429
+ }))
430
+ }
431
+ />
432
+ </div>
433
+ </div>
434
+ </div>
435
+ <DialogFooter>
436
+ <Button variant="outline" onClick={() => setCreateOpen(false)}>
437
+ {m.common_common_cancel()}
438
+ </Button>
439
+ <Button
440
+ onClick={() =>
441
+ createMutation.mutate({
442
+ code: createForm.code.trim() || undefined,
443
+ maxUses: createForm.maxUses,
444
+ validDays: createForm.validDays,
445
+ })
446
+ }
447
+ disabled={createMutation.isPending}
448
+ >
449
+ {m["admin-invitations_create_submit"]()}
450
+ </Button>
451
+ </DialogFooter>
452
+ </DialogContent>
453
+ </Dialog>
454
+
455
+ <AlertDialog
456
+ open={!!pendingDelete}
457
+ onOpenChange={(open: boolean) => !open && setPendingDelete(null)}
458
+ >
459
+ <AlertDialogContent>
460
+ <AlertDialogHeader>
461
+ <AlertDialogTitle>
462
+ {m["admin-invitations_delete_confirm_title"]({
463
+ code: pendingDelete?.code ?? "",
464
+ })}
465
+ </AlertDialogTitle>
466
+ <AlertDialogDescription>
467
+ {m["admin-invitations_delete_confirm_desc"]()}
468
+ </AlertDialogDescription>
469
+ </AlertDialogHeader>
470
+ <AlertDialogFooter>
471
+ <AlertDialogCancel>{m.common_common_cancel()}</AlertDialogCancel>
472
+ <AlertDialogAction
473
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
474
+ disabled={deleteMutation.isPending}
475
+ onClick={() =>
476
+ pendingDelete && deleteMutation.mutate(pendingDelete.id)
477
+ }
478
+ >
479
+ {m["admin-invitations_action_delete"]()}
480
+ </AlertDialogAction>
481
+ </AlertDialogFooter>
482
+ </AlertDialogContent>
483
+ </AlertDialog>
484
+
485
+ <Dialog
486
+ open={!!usesViewer}
487
+ onOpenChange={(open: boolean) => !open && setUsesViewer(null)}
488
+ >
489
+ <DialogContent>
490
+ <DialogHeader>
491
+ <DialogTitle>
492
+ {m["admin-invitations_uses_title"]({
493
+ code: usesViewer?.code ?? "",
494
+ })}
495
+ </DialogTitle>
496
+ </DialogHeader>
497
+ {usesViewer && usesViewer.uses.length === 0 ? (
498
+ <div className="py-6 text-center text-sm text-muted-foreground">
499
+ {m["admin-invitations_uses_empty"]()}
500
+ </div>
501
+ ) : usesViewer ? (
502
+ <Table>
503
+ <TableHeader>
504
+ <TableRow className="bg-muted/50">
505
+ <TableHead>{m["admin-invitations_uses_column_username"]()}</TableHead>
506
+ <TableHead>{m["admin-invitations_uses_column_usedat"]()}</TableHead>
507
+ </TableRow>
508
+ </TableHeader>
509
+ <TableBody>
510
+ {usesViewer.uses.map((u) => (
511
+ <TableRow key={u.userId}>
512
+ <TableCell>
513
+ <span className="text-sm">{u.user.username}</span>
514
+ </TableCell>
515
+ <TableCell>
516
+ <span className="text-sm">{formatTimestamp(u.usedAt)}</span>
517
+ </TableCell>
518
+ </TableRow>
519
+ ))}
520
+ </TableBody>
521
+ </Table>
522
+ ) : null}
523
+ </DialogContent>
524
+ </Dialog>
525
+ </div>
526
+ )
527
+ }