@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 +13 -8
- package/src/__tests__/components/provider-icon.test.tsx +59 -0
- package/src/__tests__/setup.ts +35 -0
- 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__/admin-billing.test.ts +182 -0
- package/src/server/__tests__/compliance.test.ts +136 -0
- package/src/server/__tests__/invitations.test.ts +311 -0
- package/src/server/__tests__/permissions.test.ts +301 -0
- package/src/server/middlewares/security-headers.ts +113 -0
- package/src/server/serverFns/components/import.ts +3 -3
- package/src/server/serverFns/invitations.ts +165 -0
- package/src/server/serverFns/oauth-providers.ts +161 -0
- package/src/start.ts +2 -1
- package/src/types/jest-dom-vitest.d.ts +31 -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",
|
|
@@ -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.
|
|
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.
|
|
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",
|
|
@@ -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": "
|
|
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": "
|
|
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
|
+
}
|