@allbluecn/admin-app 0.2.4 → 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 +4 -4
- package/src/components/invitations/invitations-page.tsx +527 -0
- package/src/components/layout/sidebar-02/app-sidebar.tsx +4 -0
- package/src/components/oauth-providers/provider-page.tsx +324 -0
- package/src/routeTree.gen.ts +46 -0
- package/src/routes/_nav/invitations/route.lazy.tsx +28 -0
- package/src/routes/_nav/invitations/route.tsx +20 -0
- package/src/routes/_nav/login-providers/route.lazy.tsx +28 -0
- package/src/routes/_nav/login-providers/route.tsx +20 -0
- package/src/server/__tests__/invitations.test.ts +311 -0
- package/src/server/serverFns/invitations.ts +165 -0
- package/src/server/serverFns/oauth-providers.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/admin-app",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -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.
|
|
46
|
-
"@allbluecn/
|
|
47
|
-
"@allbluecn/
|
|
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",
|
|
@@ -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
|
+
}
|
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
CreditCard,
|
|
46
46
|
Shield,
|
|
47
47
|
ScrollText,
|
|
48
|
+
Ticket,
|
|
49
|
+
KeyRound,
|
|
48
50
|
} from "lucide-react";
|
|
49
51
|
import { Logo } from "@/components/layout/sidebar-02/logo";
|
|
50
52
|
import type { Route } from "./nav-main";
|
|
@@ -56,6 +58,8 @@ function buildDashboardRoutes(): Route[] {
|
|
|
56
58
|
return [
|
|
57
59
|
{ id: "home", title: m.common_nav_dashboard(), icon: <Home className="size-4" />, link: "/dashboard" },
|
|
58
60
|
{ id: "users", title: m.common_nav_users(), icon: <Users className="size-4" />, link: "/users" },
|
|
61
|
+
{ id: "invitations", title: m["admin-invitations_page_title"](), icon: <Ticket className="size-4" />, link: "/invitations" },
|
|
62
|
+
{ id: "login-providers", title: m["admin-loginProviders_page_title"](), icon: <KeyRound className="size-4" />, link: "/login-providers" },
|
|
59
63
|
{ id: "docs", title: m.common_nav_docs(), icon: <FileText className="size-4" />, link: "/docs" },
|
|
60
64
|
{ id: "apps", title: m.common_nav_apps(), icon: <Package2 className="size-4" />, link: "/apps" },
|
|
61
65
|
{
|