@allbluecn/admin-app 0.1.0
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 +86 -0
- package/src/client.tsx +31 -0
- package/src/components/admin/admin-user-menu.tsx +90 -0
- package/src/components/admin/developer-log.tsx +356 -0
- package/src/components/ai-provider/model-list-editor.tsx +147 -0
- package/src/components/ai-provider/provider-form.tsx +434 -0
- package/src/components/ai-provider/provider-icon.tsx +43 -0
- package/src/components/ai-provider/provider-logo.tsx +87 -0
- package/src/components/ai-provider/provider-table.tsx +258 -0
- package/src/components/auth/admin-login-form.tsx +214 -0
- package/src/components/auth/auth-brand-panel.tsx +60 -0
- package/src/components/auth/auth-header.tsx +38 -0
- package/src/components/auth/auth-layout.tsx +66 -0
- package/src/components/auth/auth-providers.tsx +34 -0
- package/src/components/auth/icon-input.tsx +46 -0
- package/src/components/auth/oauth-section.tsx +51 -0
- package/src/components/auth/password-input.tsx +67 -0
- package/src/components/common/page-header.tsx +42 -0
- package/src/components/common/placeholder-page.tsx +42 -0
- package/src/components/component/component-form-dialog.tsx +348 -0
- package/src/components/component/component-import-dialog.tsx +235 -0
- package/src/components/component/component-table.tsx +153 -0
- package/src/components/data-table/data-table-pagination.tsx +114 -0
- package/src/components/data-table/data-table.tsx +218 -0
- package/src/components/data-table/table-detail-layout.tsx +99 -0
- package/src/components/dependency/changelog-dialog.tsx +108 -0
- package/src/components/dependency/columns.tsx +232 -0
- package/src/components/dependency/dependency-table.tsx +368 -0
- package/src/components/dependency/edit-dialog.tsx +200 -0
- package/src/components/knowledge-public/bookmark-form-dialog.tsx +215 -0
- package/src/components/knowledge-public/compliance-form-dialog.tsx +308 -0
- package/src/components/knowledge-public/legal-form-dialog.tsx +315 -0
- package/src/components/layout/sidebar-02/app-sidebar.tsx +160 -0
- package/src/components/layout/sidebar-02/index.tsx +33 -0
- package/src/components/layout/sidebar-02/logo.tsx +40 -0
- package/src/components/layout/sidebar-02/nav-main.tsx +153 -0
- package/src/components/layout/sidebar-02/nav-notifications.tsx +78 -0
- package/src/components/layout/sidebar-02/team-switcher.tsx +106 -0
- package/src/components/magicui/animated-theme-toggler.tsx +272 -0
- package/src/components/magicui/rainbow-button.tsx +78 -0
- package/src/components/market/columns.tsx +151 -0
- package/src/components/market/market-app-form.tsx +237 -0
- package/src/components/market/market-app-list.tsx +156 -0
- package/src/components/monitoring/error-list.tsx +143 -0
- package/src/components/monitoring/health-cards.tsx +152 -0
- package/src/components/monitoring/metrics-stats.tsx +184 -0
- package/src/components/monitoring/request-log-table.tsx +179 -0
- package/src/components/providers/theme-toggle.tsx +59 -0
- package/src/components/settings/avatar/avatar-config.ts +46 -0
- package/src/components/ui/alert-dialog.tsx +216 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/avatar.tsx +129 -0
- package/src/components/ui/badge.tsx +66 -0
- package/src/components/ui/button.tsx +84 -0
- package/src/components/ui/card.tsx +120 -0
- package/src/components/ui/checkbox.tsx +50 -0
- package/src/components/ui/collapsible.tsx +50 -0
- package/src/components/ui/dialog.tsx +185 -0
- package/src/components/ui/dropdown-menu.tsx +286 -0
- package/src/components/ui/field.tsx +255 -0
- package/src/components/ui/input-group.tsx +171 -0
- package/src/components/ui/input.tsx +36 -0
- package/src/components/ui/label.tsx +41 -0
- package/src/components/ui/pagination.tsx +146 -0
- package/src/components/ui/select.tsx +209 -0
- package/src/components/ui/separator.tsx +45 -0
- package/src/components/ui/sheet.tsx +164 -0
- package/src/components/ui/sidebar.tsx +719 -0
- package/src/components/ui/skeleton.tsx +30 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/switch.tsx +48 -0
- package/src/components/ui/table.tsx +133 -0
- package/src/components/ui/textarea.tsx +35 -0
- package/src/components/ui/tooltip.tsx +74 -0
- package/src/components/user/columns.tsx +153 -0
- package/src/components/user/settings-activity-log.tsx +358 -0
- package/src/components/user/settings-profile/constants.ts +42 -0
- package/src/components/user/settings-profile/types.ts +47 -0
- package/src/components/user/settings-profile.tsx +664 -0
- package/src/components/user/user-create-dialog.tsx +137 -0
- package/src/components/user/user-filters.tsx +117 -0
- package/src/hooks/use-mobile.ts +36 -0
- package/src/instrument.client.ts +34 -0
- package/src/lib/anyhub/capabilities.ts +59 -0
- package/src/lib/anyhub/types.ts +43 -0
- package/src/lib/auth-client.ts +83 -0
- package/src/lib/auth-utils.ts +24 -0
- package/src/lib/auth.config.ts +264 -0
- package/src/lib/avatar.ts +41 -0
- package/src/lib/i18n/client.ts +32 -0
- package/src/lib/i18n/config.ts +40 -0
- package/src/lib/i18n/detect.ts +43 -0
- package/src/lib/i18n/locales/en/admin-ai.json +128 -0
- package/src/lib/i18n/locales/en/admin-billing.json +56 -0
- package/src/lib/i18n/locales/en/admin-users.json +88 -0
- package/src/lib/i18n/locales/en/common.json +877 -0
- package/src/lib/i18n/locales/en/enterprise.json +208 -0
- package/src/lib/i18n/locales/zh/admin-ai.json +128 -0
- package/src/lib/i18n/locales/zh/admin-billing.json +56 -0
- package/src/lib/i18n/locales/zh/admin-users.json +88 -0
- package/src/lib/i18n/locales/zh/common.json +877 -0
- package/src/lib/i18n/locales/zh/enterprise.json +208 -0
- package/src/lib/i18n/server.ts +18 -0
- package/src/lib/i18n/types.ts +5 -0
- package/src/lib/monitoring-benchmarks.ts +85 -0
- package/src/lib/monitoring-descriptions.ts +78 -0
- package/src/lib/prisma.ts +18 -0
- package/src/lib/router-context.ts +38 -0
- package/src/lib/session.ts +38 -0
- package/src/lib/utils.ts +23 -0
- package/src/providers/index.tsx +45 -0
- package/src/routeTree.gen.ts +1199 -0
- package/src/router.tsx +51 -0
- package/src/routes/__root.tsx +170 -0
- package/src/routes/_nav/ai-providers/$id/edit/route.lazy.tsx +74 -0
- package/src/routes/_nav/ai-providers/$id/edit/route.tsx +35 -0
- package/src/routes/_nav/ai-providers/index/route.lazy.tsx +33 -0
- package/src/routes/_nav/ai-providers/new/route.lazy.tsx +31 -0
- package/src/routes/_nav/ai-providers/new/route.tsx +20 -0
- package/src/routes/_nav/ai-providers/route.lazy.tsx +26 -0
- package/src/routes/_nav/ai-providers/route.tsx +31 -0
- package/src/routes/_nav/apps/route.lazy.tsx +30 -0
- package/src/routes/_nav/apps/route.tsx +20 -0
- package/src/routes/_nav/audit/route.lazy.tsx +100 -0
- package/src/routes/_nav/audit/route.tsx +25 -0
- package/src/routes/_nav/billing/invoices/route.lazy.tsx +86 -0
- package/src/routes/_nav/billing/invoices/route.tsx +25 -0
- package/src/routes/_nav/billing/route.lazy.tsx +48 -0
- package/src/routes/_nav/billing/route.tsx +27 -0
- package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.lazy.tsx +160 -0
- package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.tsx +25 -0
- package/src/routes/_nav/billing/subscriptions/route.lazy.tsx +113 -0
- package/src/routes/_nav/billing/subscriptions/route.tsx +28 -0
- package/src/routes/_nav/compliance/route.lazy.tsx +184 -0
- package/src/routes/_nav/compliance/route.tsx +25 -0
- package/src/routes/_nav/components/route.lazy.tsx +184 -0
- package/src/routes/_nav/components/route.tsx +40 -0
- package/src/routes/_nav/dashboard/route.lazy.tsx +33 -0
- package/src/routes/_nav/dashboard/route.tsx +20 -0
- package/src/routes/_nav/dependencies/route.lazy.tsx +66 -0
- package/src/routes/_nav/dependencies/route.tsx +32 -0
- package/src/routes/_nav/developer-logs/route.lazy.tsx +6 -0
- package/src/routes/_nav/developer-logs/route.tsx +14 -0
- package/src/routes/_nav/docs/route.lazy.tsx +30 -0
- package/src/routes/_nav/docs/route.tsx +20 -0
- package/src/routes/_nav/knowledge-public/bookmarks/route.lazy.tsx +355 -0
- package/src/routes/_nav/knowledge-public/bookmarks/route.tsx +40 -0
- package/src/routes/_nav/knowledge-public/compliance/route.lazy.tsx +245 -0
- package/src/routes/_nav/knowledge-public/compliance/route.tsx +40 -0
- package/src/routes/_nav/knowledge-public/legal/route.lazy.tsx +242 -0
- package/src/routes/_nav/knowledge-public/legal/route.tsx +40 -0
- package/src/routes/_nav/licenses/$licenseId/route.lazy.tsx +235 -0
- package/src/routes/_nav/licenses/$licenseId/route.tsx +25 -0
- package/src/routes/_nav/licenses/new/route.lazy.tsx +237 -0
- package/src/routes/_nav/licenses/route.lazy.tsx +143 -0
- package/src/routes/_nav/licenses/route.tsx +25 -0
- package/src/routes/_nav/market/route.lazy.tsx +31 -0
- package/src/routes/_nav/market/route.tsx +20 -0
- package/src/routes/_nav/monitoring/ai-usage/route.lazy.tsx +305 -0
- package/src/routes/_nav/monitoring/ai-usage/route.tsx +3 -0
- package/src/routes/_nav/monitoring/errors/route.lazy.tsx +111 -0
- package/src/routes/_nav/monitoring/health/route.lazy.tsx +260 -0
- package/src/routes/_nav/monitoring/logs/route.lazy.tsx +165 -0
- package/src/routes/_nav/monitoring/metrics/route.lazy.tsx +240 -0
- package/src/routes/_nav/monitoring/overview/route.lazy.tsx +138 -0
- package/src/routes/_nav/monitoring/route.lazy.tsx +68 -0
- package/src/routes/_nav/monitoring/route.tsx +20 -0
- package/src/routes/_nav/monitoring/sentry/route.lazy.tsx +399 -0
- package/src/routes/_nav/monitoring/sentry/route.tsx +20 -0
- package/src/routes/_nav/permissions/route.lazy.tsx +504 -0
- package/src/routes/_nav/permissions/route.tsx +26 -0
- package/src/routes/_nav/route.lazy.tsx +31 -0
- package/src/routes/_nav/route.tsx +34 -0
- package/src/routes/_nav/settings/route.lazy.tsx +30 -0
- package/src/routes/_nav/settings/route.tsx +20 -0
- package/src/routes/_nav/sso/route.lazy.tsx +145 -0
- package/src/routes/_nav/sso/route.tsx +25 -0
- package/src/routes/_nav/users/$userId/route.lazy.tsx +432 -0
- package/src/routes/_nav/users/$userId/route.tsx +27 -0
- package/src/routes/_nav/users/-components/users-table.tsx +38 -0
- package/src/routes/_nav/users/route.lazy.tsx +74 -0
- package/src/routes/_nav/users/route.tsx +43 -0
- package/src/routes/api/compliance-download/$jobId.ts +35 -0
- package/src/routes/api/observability/errors.ts +41 -0
- package/src/routes/api/observability/health.ts +30 -0
- package/src/routes/api.auth.$.ts +31 -0
- package/src/routes/api.sentry-test.ts +33 -0
- package/src/routes/index/route.lazy.tsx +22 -0
- package/src/routes/index/route.tsx +24 -0
- package/src/routes/login/route.lazy.tsx +46 -0
- package/src/routes/login/route.tsx +28 -0
- package/src/routes/sentry-tunnel.ts +61 -0
- package/src/routes/setup/route.lazy.tsx +94 -0
- package/src/routes/setup/route.tsx +28 -0
- package/src/server/__tests__/admin-ai-alerts.test.ts +113 -0
- package/src/server/__tests__/licenses-issue.test.ts +98 -0
- package/src/server/admin-ai-alerts.ts +177 -0
- package/src/server/admin-ai-usage.ts +134 -0
- package/src/server/auth-helpers.ts +51 -0
- package/src/server/entitlements.ts +24 -0
- package/src/server/knowledge-public/canonical-id.ts +55 -0
- package/src/server/knowledge-public/github-sync.ts +86 -0
- package/src/server/license-issue.ts +78 -0
- package/src/server/middlewares/request.ts +26 -0
- package/src/server/observability/debug-middleware.ts +39 -0
- package/src/server/observability/error-reporter.ts +99 -0
- package/src/server/observability/health-check.ts +140 -0
- package/src/server/observability/index.ts +33 -0
- package/src/server/observability/logger.ts +49 -0
- package/src/server/observability/metrics.ts +105 -0
- package/src/server/observability/request-middleware.ts +125 -0
- package/src/server/observability/timing.ts +42 -0
- package/src/server/pro-services.ts +32 -0
- package/src/server/serverFns/admin-billing.ts +94 -0
- package/src/server/serverFns/ai-provider/create.ts +92 -0
- package/src/server/serverFns/ai-provider/delete.ts +42 -0
- package/src/server/serverFns/ai-provider/get.ts +35 -0
- package/src/server/serverFns/ai-provider/list.ts +92 -0
- package/src/server/serverFns/ai-provider/update.ts +73 -0
- package/src/server/serverFns/anyhub/market.ts +64 -0
- package/src/server/serverFns/audit.ts +57 -0
- package/src/server/serverFns/auth/session.ts +26 -0
- package/src/server/serverFns/auth/setup.ts +73 -0
- package/src/server/serverFns/compliance.ts +85 -0
- package/src/server/serverFns/components/import.ts +197 -0
- package/src/server/serverFns/components/list.ts +51 -0
- package/src/server/serverFns/components/mutations.ts +84 -0
- package/src/server/serverFns/dependency/helpers.ts +245 -0
- package/src/server/serverFns/dependency/list.ts +127 -0
- package/src/server/serverFns/dependency/sync.ts +123 -0
- package/src/server/serverFns/dependency/update.ts +51 -0
- package/src/server/serverFns/developer-log-auto-generate.ts +176 -0
- package/src/server/serverFns/developer-logs.ts +132 -0
- package/src/server/serverFns/knowledge-public/bookmarks/create.ts +53 -0
- package/src/server/serverFns/knowledge-public/bookmarks/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/bookmarks/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/bookmarks/list.ts +66 -0
- package/src/server/serverFns/knowledge-public/bookmarks/sync-all-github.ts +56 -0
- package/src/server/serverFns/knowledge-public/bookmarks/update.ts +50 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +58 -0
- package/src/server/serverFns/knowledge-public/compliance/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/compliance/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/compliance/list.ts +59 -0
- package/src/server/serverFns/knowledge-public/compliance/update.ts +53 -0
- package/src/server/serverFns/knowledge-public/legal/create.ts +62 -0
- package/src/server/serverFns/knowledge-public/legal/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/legal/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/legal/list.ts +60 -0
- package/src/server/serverFns/knowledge-public/legal/update.ts +55 -0
- package/src/server/serverFns/licenses.ts +92 -0
- package/src/server/serverFns/locale.ts +10 -0
- package/src/server/serverFns/monitoring/check-ai-alerts.ts +13 -0
- package/src/server/serverFns/monitoring/get-ai-usage-overview.ts +25 -0
- package/src/server/serverFns/monitoring/get-errors.ts +43 -0
- package/src/server/serverFns/monitoring/get-health-settings.ts +35 -0
- package/src/server/serverFns/monitoring/get-health.ts +55 -0
- package/src/server/serverFns/monitoring/get-metrics.ts +46 -0
- package/src/server/serverFns/monitoring/get-request-logs.ts +66 -0
- package/src/server/serverFns/monitoring/report-error.ts +55 -0
- package/src/server/serverFns/monitoring/reset.ts +70 -0
- package/src/server/serverFns/permissions.ts +214 -0
- package/src/server/serverFns/sentry/config.ts +88 -0
- package/src/server/serverFns/sso-config.ts +83 -0
- package/src/server/serverFns/user/create.ts +40 -0
- package/src/server/serverFns/user/delete.ts +29 -0
- package/src/server/serverFns/user/detail.ts +45 -0
- package/src/server/serverFns/user/list.ts +56 -0
- package/src/server/serverFns/user/reset-password.ts +28 -0
- package/src/server/serverFns/user/update-role.ts +26 -0
- package/src/server/serverFns/user/update-status.ts +26 -0
- package/src/server.ts +27 -0
- package/src/start.ts +85 -0
- package/src/styles/_keyframe-animations.scss +91 -0
- package/src/styles/_variables.scss +296 -0
- package/src/styles/globals.css +200 -0
- package/src/types/start-authjs.d.ts +27 -0
|
@@ -0,0 +1,145 @@
|
|
|
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 { createLazyFileRoute } from '@tanstack/react-router'
|
|
19
|
+
import { useServerFn } from '@tanstack/react-start'
|
|
20
|
+
import { useTranslation } from 'react-i18next'
|
|
21
|
+
import { toast } from 'sonner'
|
|
22
|
+
import type React from 'react'
|
|
23
|
+
import { deleteSsoProviderFn, toggleSsoProviderFn } from '@/server/serverFns/sso-config'
|
|
24
|
+
import { Badge } from '@/components/ui/badge'
|
|
25
|
+
|
|
26
|
+
export const Route = createLazyFileRoute('/_nav/sso')({
|
|
27
|
+
pendingComponent: () => (
|
|
28
|
+
<div className="flex h-64 items-center justify-center">
|
|
29
|
+
<div className="size-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
component: SsoConfigPage,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function SsoConfigPage(): React.ReactElement {
|
|
36
|
+
const { t } = useTranslation('enterprise')
|
|
37
|
+
const { items, total } = Route.useLoaderData() as {
|
|
38
|
+
items: Array<{
|
|
39
|
+
id: string
|
|
40
|
+
workspaceId: string
|
|
41
|
+
type: string
|
|
42
|
+
name: string
|
|
43
|
+
config: Record<string, unknown>
|
|
44
|
+
domain: string | null
|
|
45
|
+
enabled: boolean
|
|
46
|
+
createdAt: string
|
|
47
|
+
updatedAt: string
|
|
48
|
+
_count: { mappings: number }
|
|
49
|
+
}>
|
|
50
|
+
total: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const toggleFn = useServerFn(toggleSsoProviderFn)
|
|
54
|
+
const deleteFn = useServerFn(deleteSsoProviderFn)
|
|
55
|
+
|
|
56
|
+
const handleToggle = async (provider: typeof items[0]) => {
|
|
57
|
+
await toggleFn({ data: { providerId: provider.id, enabled: !provider.enabled } })
|
|
58
|
+
const key = !provider.enabled ? 'enabled' : 'disabled'
|
|
59
|
+
toast.success(t('sso.toast.' + key, { name: provider.name }))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const handleDelete = async (provider: typeof items[0]) => {
|
|
63
|
+
await deleteFn({ data: { providerId: provider.id } })
|
|
64
|
+
toast.success(t('sso.toast.deleted', { name: provider.name }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
68
|
+
google: 'Google Workspace',
|
|
69
|
+
oidc: 'OIDC',
|
|
70
|
+
saml: 'SAML',
|
|
71
|
+
okta: 'Okta',
|
|
72
|
+
azure: 'Azure AD',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className="space-y-4">
|
|
77
|
+
<div>
|
|
78
|
+
<h1 className="text-2xl font-bold">{t('sso.title')}</h1>
|
|
79
|
+
<p className="text-sm text-muted-foreground mt-1">{t('sso.subtitle', { count: total })}</p>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{items.length === 0 ? (
|
|
83
|
+
<div className="rounded-lg border border-border p-8 text-center text-muted-foreground">
|
|
84
|
+
{t('sso.empty')}
|
|
85
|
+
</div>
|
|
86
|
+
) : (
|
|
87
|
+
<div className="rounded-lg border border-border overflow-hidden">
|
|
88
|
+
<table className="w-full text-sm">
|
|
89
|
+
<thead>
|
|
90
|
+
<tr className="border-b border-border bg-muted/50">
|
|
91
|
+
<th className="p-3 text-left font-medium">{t('sso.table.name')}</th>
|
|
92
|
+
<th className="p-3 text-left font-medium">{t('sso.table.type')}</th>
|
|
93
|
+
<th className="p-3 text-left font-medium">{t('sso.table.domain')}</th>
|
|
94
|
+
<th className="p-3 text-left font-medium">{t('sso.table.workspace')}</th>
|
|
95
|
+
<th className="p-3 text-left font-medium">{t('sso.table.mappings')}</th>
|
|
96
|
+
<th className="p-3 text-left font-medium">{t('sso.table.status')}</th>
|
|
97
|
+
<th className="p-3 text-left font-medium">{t('sso.table.actions')}</th>
|
|
98
|
+
</tr>
|
|
99
|
+
</thead>
|
|
100
|
+
<tbody>
|
|
101
|
+
{items.map((provider) => (
|
|
102
|
+
<tr key={provider.id} className="border-b border-border last:border-0">
|
|
103
|
+
<td className="p-3 font-medium">{provider.name}</td>
|
|
104
|
+
<td className="p-3">
|
|
105
|
+
<Badge variant="outline">{TYPE_LABELS[provider.type] ?? provider.type}</Badge>
|
|
106
|
+
</td>
|
|
107
|
+
<td className="p-3">{provider.domain || '—'}</td>
|
|
108
|
+
<td className="p-3">{provider.workspaceId.slice(0, 8)}</td>
|
|
109
|
+
<td className="p-3">{provider._count?.mappings ?? '—'}</td>
|
|
110
|
+
<td className="p-3">
|
|
111
|
+
<span
|
|
112
|
+
className={`inline-flex rounded-md px-2 py-0.5 text-xs ${
|
|
113
|
+
provider.enabled
|
|
114
|
+
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
|
115
|
+
: 'bg-muted text-muted-foreground'
|
|
116
|
+
}`}
|
|
117
|
+
>
|
|
118
|
+
{provider.enabled ? t('sso.status.enabled') : t('sso.status.disabled')}
|
|
119
|
+
</span>
|
|
120
|
+
</td>
|
|
121
|
+
<td className="p-3 flex items-center gap-2">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className="text-primary hover:underline"
|
|
125
|
+
onClick={() => handleToggle(provider)}
|
|
126
|
+
>
|
|
127
|
+
{provider.enabled ? t('sso.action.disable') : t('sso.action.enable')}
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="text-destructive hover:underline"
|
|
132
|
+
onClick={() => handleDelete(provider)}
|
|
133
|
+
>
|
|
134
|
+
{t('sso.action.delete')}
|
|
135
|
+
</button>
|
|
136
|
+
</td>
|
|
137
|
+
</tr>
|
|
138
|
+
))}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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 { createFileRoute } from '@tanstack/react-router'
|
|
19
|
+
import { listSsoProvidersFn } from '@/server/serverFns/sso-config'
|
|
20
|
+
|
|
21
|
+
export const Route = createFileRoute('/_nav/sso')({
|
|
22
|
+
loader: async () => {
|
|
23
|
+
return listSsoProvidersFn({ data: {} })
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,432 @@
|
|
|
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 { createLazyFileRoute } from '@tanstack/react-router'
|
|
19
|
+
import { useState, useTransition, useMemo } from 'react'
|
|
20
|
+
import { I18nextProvider } from 'react-i18next'
|
|
21
|
+
import { useTranslation } from 'react-i18next'
|
|
22
|
+
import { createServerI18n } from '@/lib/i18n/server'
|
|
23
|
+
import { formatTimestamp } from '@allbluecn/web-app/lib/datetime'
|
|
24
|
+
import { getUserDetailFn } from '@/server/serverFns/user/detail'
|
|
25
|
+
import { updateUserRoleFn } from '@/server/serverFns/user/update-role'
|
|
26
|
+
import { updateUserStatusFn } from '@/server/serverFns/user/update-status'
|
|
27
|
+
import { resetUserPasswordFn } from '@/server/serverFns/user/reset-password'
|
|
28
|
+
import { deleteUserFn } from '@/server/serverFns/user/delete'
|
|
29
|
+
import { TableDetailLayout } from '@/components/data-table/table-detail-layout'
|
|
30
|
+
import { Badge } from '@/components/ui/badge'
|
|
31
|
+
import { Separator } from '@/components/ui/separator'
|
|
32
|
+
import { Button } from '@/components/ui/button'
|
|
33
|
+
import { UserAvatar } from '@allbluecn/web-app/user-avatar'
|
|
34
|
+
import {
|
|
35
|
+
Dialog,
|
|
36
|
+
DialogContent,
|
|
37
|
+
DialogHeader,
|
|
38
|
+
DialogTitle,
|
|
39
|
+
DialogFooter,
|
|
40
|
+
} from '@/components/ui/dialog'
|
|
41
|
+
import {
|
|
42
|
+
AlertDialog,
|
|
43
|
+
AlertDialogAction,
|
|
44
|
+
AlertDialogCancel,
|
|
45
|
+
AlertDialogContent,
|
|
46
|
+
AlertDialogDescription,
|
|
47
|
+
AlertDialogFooter,
|
|
48
|
+
AlertDialogHeader,
|
|
49
|
+
AlertDialogTitle,
|
|
50
|
+
} from '@/components/ui/alert-dialog'
|
|
51
|
+
import {
|
|
52
|
+
Select,
|
|
53
|
+
SelectContent,
|
|
54
|
+
SelectItem,
|
|
55
|
+
SelectTrigger,
|
|
56
|
+
SelectValue,
|
|
57
|
+
} from '@/components/ui/select'
|
|
58
|
+
import { Input } from '@/components/ui/input'
|
|
59
|
+
import { Label } from '@/components/ui/label'
|
|
60
|
+
import {
|
|
61
|
+
AlertCircle,
|
|
62
|
+
FileTextIcon,
|
|
63
|
+
Package2Icon,
|
|
64
|
+
ShoppingBagIcon,
|
|
65
|
+
ShieldCheckIcon,
|
|
66
|
+
} from 'lucide-react'
|
|
67
|
+
import { toast } from '@/components/ui/sonner'
|
|
68
|
+
import { parseUserAgent, formatParsedUA } from '@allbluecn/kernel'
|
|
69
|
+
import type { AvatarConfig } from '@allbluecn/kernel'
|
|
70
|
+
import SettingsActivityLog, {
|
|
71
|
+
type ActivityLogEntry,
|
|
72
|
+
} from '@/components/user/settings-activity-log'
|
|
73
|
+
import SettingsProfile from '@/components/user/settings-profile'
|
|
74
|
+
import {
|
|
75
|
+
Card,
|
|
76
|
+
CardContent,
|
|
77
|
+
CardDescription,
|
|
78
|
+
CardHeader,
|
|
79
|
+
CardTitle,
|
|
80
|
+
} from '@/components/ui/card'
|
|
81
|
+
|
|
82
|
+
export const Route = createLazyFileRoute('/_nav/users/$userId')({
|
|
83
|
+
pendingComponent: () => (
|
|
84
|
+
<div className="flex h-64 items-center justify-center">
|
|
85
|
+
<div className="size-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
88
|
+
errorComponent: AdminUserErrorBoundary,
|
|
89
|
+
component: UserDetailPage,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
function AdminUserErrorBoundary({ error, reset }: { error: Error; reset: () => void }) {
|
|
93
|
+
const i18n = useMemo(() => createServerI18n('zh'), [])
|
|
94
|
+
return (
|
|
95
|
+
<I18nextProvider i18n={i18n}>
|
|
96
|
+
<AdminUserErrorBoundaryInner error={error} reset={reset} />
|
|
97
|
+
</I18nextProvider>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function AdminUserErrorBoundaryInner({ error, reset }: { error: Error; reset: () => void }) {
|
|
102
|
+
const { t } = useTranslation('admin-users')
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex flex-col items-center justify-center h-64 gap-3">
|
|
105
|
+
<AlertCircle className="size-8 text-destructive" />
|
|
106
|
+
<p className="text-sm text-muted-foreground">{t('detail.errorLoading')}: {error.message}</p>
|
|
107
|
+
<Button variant="outline" size="sm" onClick={reset}>{t('detail.retry')}</Button>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function UserDetailPage() {
|
|
113
|
+
const { t } = useTranslation('admin-users')
|
|
114
|
+
const statusBadgeVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
115
|
+
ACTIVE: 'default',
|
|
116
|
+
INACTIVE: 'secondary',
|
|
117
|
+
BANNED: 'destructive',
|
|
118
|
+
}
|
|
119
|
+
const data = Route.useLoaderData() as any
|
|
120
|
+
const notFound = data.notFound
|
|
121
|
+
const [user, setUser] = useState(data.user)
|
|
122
|
+
const [isPending, startTransition] = useTransition()
|
|
123
|
+
|
|
124
|
+
if (notFound) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="flex h-64 items-center justify-center">
|
|
127
|
+
<p className="text-muted-foreground">{t('detail.userNotFound')}</p>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!user) return null
|
|
133
|
+
|
|
134
|
+
const [roleDialog, setRoleDialog] = useState({ open: false, value: '' })
|
|
135
|
+
const [statusDialog, setStatusDialog] = useState({ open: false, value: '' })
|
|
136
|
+
const [passwordDialog, setPasswordDialog] = useState({ open: false, value: '' })
|
|
137
|
+
const [deleteDialog, setDeleteDialog] = useState({ open: false, confirmName: '' })
|
|
138
|
+
|
|
139
|
+
const refetchUser = () => startTransition(async () => {
|
|
140
|
+
const updated = await getUserDetailFn({ data: { userId: user.id } })
|
|
141
|
+
if (updated) setUser(updated)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const getRoleLabel = (role: string) => t(`role.${role}`)
|
|
145
|
+
const getStatusLabel = (status: string) => t(`status.${status}`)
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<>
|
|
149
|
+
<TableDetailLayout
|
|
150
|
+
backHref="/users"
|
|
151
|
+
title={user.username}
|
|
152
|
+
subtitle={user.email}
|
|
153
|
+
avatar={
|
|
154
|
+
<UserAvatar
|
|
155
|
+
avatarConfig={user.avatarConfig as AvatarConfig | null}
|
|
156
|
+
bgShape={user.bgShape as 'square' | 'rounded' | null}
|
|
157
|
+
bgColor={user.bgColor}
|
|
158
|
+
name={user.username}
|
|
159
|
+
size="lg"
|
|
160
|
+
/>
|
|
161
|
+
}
|
|
162
|
+
badges={
|
|
163
|
+
<>
|
|
164
|
+
<Badge>{getRoleLabel(user.role) || user.role}</Badge>
|
|
165
|
+
<Badge variant={statusBadgeVariant[user.status] || 'outline'}>
|
|
166
|
+
{getStatusLabel(user.status) || user.status}
|
|
167
|
+
</Badge>
|
|
168
|
+
</>
|
|
169
|
+
}
|
|
170
|
+
>
|
|
171
|
+
<Separator />
|
|
172
|
+
|
|
173
|
+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
174
|
+
{/* 左列:用户详情 + 账号信息 + 关联统计 */}
|
|
175
|
+
<div className="space-y-6">
|
|
176
|
+
<SettingsProfile
|
|
177
|
+
readOnly
|
|
178
|
+
profile={{ name: user.username, email: user.email }}
|
|
179
|
+
title={t('detail.profile.title')}
|
|
180
|
+
description={t('detail.profile.description')}
|
|
181
|
+
actions={
|
|
182
|
+
<>
|
|
183
|
+
<Button variant="outline" size="sm" onClick={() => setRoleDialog({ open: true, value: user.role })}>
|
|
184
|
+
{t('action.changeRole')}
|
|
185
|
+
</Button>
|
|
186
|
+
<Button variant="outline" size="sm" onClick={() => setStatusDialog({ open: true, value: user.status })}>
|
|
187
|
+
{t('action.changeStatus')}
|
|
188
|
+
</Button>
|
|
189
|
+
<Button variant="outline" size="sm" onClick={() => setPasswordDialog({ open: true, value: '' })}>
|
|
190
|
+
{t('action.resetPassword')}
|
|
191
|
+
</Button>
|
|
192
|
+
<Button variant="destructive" size="sm" onClick={() => setDeleteDialog({ open: true, confirmName: '' })}>
|
|
193
|
+
{t('common:button.delete')}
|
|
194
|
+
</Button>
|
|
195
|
+
</>
|
|
196
|
+
}
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
<Card className="shadow-xs">
|
|
200
|
+
<CardHeader>
|
|
201
|
+
<CardTitle>{t('detail.account.title')}</CardTitle>
|
|
202
|
+
<CardDescription>{t('detail.account.description')}</CardDescription>
|
|
203
|
+
</CardHeader>
|
|
204
|
+
<CardContent>
|
|
205
|
+
<div className="space-y-2 text-sm">
|
|
206
|
+
<div className="flex justify-between rounded-lg border p-3">
|
|
207
|
+
<span className="text-muted-foreground">{t('detail.account.createdAt')}</span>
|
|
208
|
+
<span>{formatTimestamp(user.createdAt)}</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex justify-between rounded-lg border p-3">
|
|
211
|
+
<span className="text-muted-foreground">{t('detail.account.updatedAt')}</span>
|
|
212
|
+
<span>{formatTimestamp(user.updatedAt)}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="flex justify-between rounded-lg border p-3">
|
|
215
|
+
<span className="text-muted-foreground">{t('detail.account.lastLogin')}</span>
|
|
216
|
+
<span>{user.lastLoginAt ? formatTimestamp(user.lastLoginAt) : t('detail.account.neverLoggedIn')}</span>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</CardContent>
|
|
220
|
+
</Card>
|
|
221
|
+
|
|
222
|
+
<Card className="shadow-xs">
|
|
223
|
+
<CardHeader>
|
|
224
|
+
<CardTitle>{t('detail.stats.title')}</CardTitle>
|
|
225
|
+
<CardDescription>{t('detail.stats.description')}</CardDescription>
|
|
226
|
+
</CardHeader>
|
|
227
|
+
<CardContent>
|
|
228
|
+
<div className="grid grid-cols-2 gap-3">
|
|
229
|
+
<div className="flex items-center gap-2 rounded-lg border p-3">
|
|
230
|
+
<FileTextIcon className="h-4 w-4 text-muted-foreground" />
|
|
231
|
+
<div>
|
|
232
|
+
<div className="text-lg font-semibold">{user._count.prds}</div>
|
|
233
|
+
<div className="text-xs text-muted-foreground">{t('detail.stats.prds')}</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="flex items-center gap-2 rounded-lg border p-3">
|
|
237
|
+
<Package2Icon className="h-4 w-4 text-muted-foreground" />
|
|
238
|
+
<div>
|
|
239
|
+
<div className="text-lg font-semibold">{user._count.anyhubApps}</div>
|
|
240
|
+
<div className="text-xs text-muted-foreground">{t('detail.stats.apps')}</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="flex items-center gap-2 rounded-lg border p-3">
|
|
244
|
+
<ShoppingBagIcon className="h-4 w-4 text-muted-foreground" />
|
|
245
|
+
<div>
|
|
246
|
+
<div className="text-lg font-semibold">{user._count.anyhubMarketApps}</div>
|
|
247
|
+
<div className="text-xs text-muted-foreground">{t('detail.stats.marketApps')}</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<div className="flex items-center gap-2 rounded-lg border p-3">
|
|
251
|
+
<ShieldCheckIcon className="h-4 w-4 text-muted-foreground" />
|
|
252
|
+
<div>
|
|
253
|
+
<div className="text-lg font-semibold">{user._count.reviews}</div>
|
|
254
|
+
<div className="text-xs text-muted-foreground">{t('detail.stats.reviews')}</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</CardContent>
|
|
259
|
+
</Card>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* 右列:最近登录(下方空白) */}
|
|
263
|
+
<div>
|
|
264
|
+
<SettingsActivityLog
|
|
265
|
+
entries={user.loginLogs.map((log: any): ActivityLogEntry => ({
|
|
266
|
+
id: log.id,
|
|
267
|
+
action: 'login',
|
|
268
|
+
type: 'login',
|
|
269
|
+
description: log.success ? t('detail.loginLogs.success') : t('detail.loginLogs.failure'),
|
|
270
|
+
ipAddress: log.ip || '',
|
|
271
|
+
location: log.location || '',
|
|
272
|
+
device: formatParsedUA(parseUserAgent(log.userAgent)),
|
|
273
|
+
timestamp: new Date(log.createdAt),
|
|
274
|
+
status: log.success ? 'success' : 'failed',
|
|
275
|
+
reason: log.success ? undefined : log.message || t('detail.loginLogs.unknownReason'),
|
|
276
|
+
}))}
|
|
277
|
+
title={t('detail.loginLogs.title')}
|
|
278
|
+
description={t('detail.loginLogs.description')}
|
|
279
|
+
onExport={async (filters) => {
|
|
280
|
+
const rows = user.loginLogs
|
|
281
|
+
.filter((log: any) => {
|
|
282
|
+
if (filters.type && filters.type !== 'login') return false
|
|
283
|
+
if (
|
|
284
|
+
filters.search &&
|
|
285
|
+
!`${log.success ? t('detail.loginLogs.success') : t('detail.loginLogs.failure')} ${log.ip || ''}`
|
|
286
|
+
.toLowerCase()
|
|
287
|
+
.includes(filters.search.toLowerCase())
|
|
288
|
+
)
|
|
289
|
+
return false
|
|
290
|
+
return true
|
|
291
|
+
})
|
|
292
|
+
.map((log: any) => {
|
|
293
|
+
const parsed = parseUserAgent(log.userAgent)
|
|
294
|
+
return [
|
|
295
|
+
new Date(log.createdAt).toISOString(),
|
|
296
|
+
log.success ? t('detail.loginLogs.success') : t('detail.loginLogs.failure'),
|
|
297
|
+
log.success ? '' : log.message || t('detail.loginLogs.unknownReason'),
|
|
298
|
+
log.ip || '',
|
|
299
|
+
log.location || '',
|
|
300
|
+
parsed.browser,
|
|
301
|
+
parsed.os,
|
|
302
|
+
parsed.device,
|
|
303
|
+
]
|
|
304
|
+
.map((v: string) => `"${(v || '').replace(/"/g, '""')}"`)
|
|
305
|
+
.join(',')
|
|
306
|
+
})
|
|
307
|
+
const csv = [t('detail.loginLogs.csvHeader'), ...rows].join('\n')
|
|
308
|
+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
309
|
+
const url = URL.createObjectURL(blob)
|
|
310
|
+
const a = document.createElement('a')
|
|
311
|
+
a.href = url
|
|
312
|
+
a.download = `user-${user.username}-login-logs.csv`
|
|
313
|
+
a.click()
|
|
314
|
+
URL.revokeObjectURL(url)
|
|
315
|
+
toast.success(t('detail.loginLogs.exportSuccess'))
|
|
316
|
+
}}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</TableDetailLayout>
|
|
321
|
+
|
|
322
|
+
<Dialog open={roleDialog.open} onOpenChange={(o) => setRoleDialog((d) => ({ ...d, open: o }))}>
|
|
323
|
+
<DialogContent className="sm:max-w-100">
|
|
324
|
+
<DialogHeader>
|
|
325
|
+
<DialogTitle>{t('dialog.role.title', { username: user.username })}</DialogTitle>
|
|
326
|
+
</DialogHeader>
|
|
327
|
+
<div className="py-4">
|
|
328
|
+
<Label>{t('dialog.role.newRole')}</Label>
|
|
329
|
+
<Select value={roleDialog.value} onValueChange={(v) => setRoleDialog((d) => ({ ...d, value: v }))}>
|
|
330
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
331
|
+
<SelectContent>
|
|
332
|
+
<SelectItem value="USER">{t('role.user')}</SelectItem>
|
|
333
|
+
<SelectItem value="ADMIN">{t('role.admin')}</SelectItem>
|
|
334
|
+
<SelectItem value="SUPER_ADMIN">{t('role.superAdmin')}</SelectItem>
|
|
335
|
+
</SelectContent>
|
|
336
|
+
</Select>
|
|
337
|
+
</div>
|
|
338
|
+
<DialogFooter>
|
|
339
|
+
<Button variant="outline" onClick={() => setRoleDialog((d) => ({ ...d, open: false }))}>{t('common:button.cancel')}</Button>
|
|
340
|
+
<Button disabled={isPending} onClick={() => startTransition(async () => {
|
|
341
|
+
try {
|
|
342
|
+
await updateUserRoleFn({ data: { userId: user.id, role: roleDialog.value as 'USER' | 'ADMIN' | 'SUPER_ADMIN' } })
|
|
343
|
+
toast.success(t('toast.roleUpdated'))
|
|
344
|
+
setRoleDialog((d) => ({ ...d, open: false }))
|
|
345
|
+
await refetchUser()
|
|
346
|
+
} catch { toast.error(t('toast.updateFailed')) }
|
|
347
|
+
})}>{t('common:button.confirm')}</Button>
|
|
348
|
+
</DialogFooter>
|
|
349
|
+
</DialogContent>
|
|
350
|
+
</Dialog>
|
|
351
|
+
|
|
352
|
+
<Dialog open={statusDialog.open} onOpenChange={(o) => setStatusDialog((d) => ({ ...d, open: o }))}>
|
|
353
|
+
<DialogContent className="sm:max-w-100">
|
|
354
|
+
<DialogHeader>
|
|
355
|
+
<DialogTitle>{t('dialog.status.title', { username: user.username })}</DialogTitle>
|
|
356
|
+
</DialogHeader>
|
|
357
|
+
<div className="py-4">
|
|
358
|
+
<Label>{t('dialog.status.newStatus')}</Label>
|
|
359
|
+
<Select value={statusDialog.value} onValueChange={(v) => setStatusDialog((d) => ({ ...d, value: v }))}>
|
|
360
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
361
|
+
<SelectContent>
|
|
362
|
+
<SelectItem value="ACTIVE">{t('status.active')}</SelectItem>
|
|
363
|
+
<SelectItem value="INACTIVE">{t('status.inactive')}</SelectItem>
|
|
364
|
+
<SelectItem value="BANNED">{t('status.banned')}</SelectItem>
|
|
365
|
+
</SelectContent>
|
|
366
|
+
</Select>
|
|
367
|
+
</div>
|
|
368
|
+
<DialogFooter>
|
|
369
|
+
<Button variant="outline" onClick={() => setStatusDialog((d) => ({ ...d, open: false }))}>{t('common:button.cancel')}</Button>
|
|
370
|
+
<Button disabled={isPending} onClick={() => startTransition(async () => {
|
|
371
|
+
try {
|
|
372
|
+
await updateUserStatusFn({ data: { userId: user.id, status: statusDialog.value as 'ACTIVE' | 'INACTIVE' | 'BANNED' } })
|
|
373
|
+
toast.success(t('toast.statusUpdated'))
|
|
374
|
+
setStatusDialog((d) => ({ ...d, open: false }))
|
|
375
|
+
await refetchUser()
|
|
376
|
+
} catch { toast.error(t('toast.updateFailed')) }
|
|
377
|
+
})}>{t('common:button.confirm')}</Button>
|
|
378
|
+
</DialogFooter>
|
|
379
|
+
</DialogContent>
|
|
380
|
+
</Dialog>
|
|
381
|
+
|
|
382
|
+
<Dialog open={passwordDialog.open} onOpenChange={(o) => setPasswordDialog((d) => ({ ...d, open: o }))}>
|
|
383
|
+
<DialogContent className="sm:max-w-100">
|
|
384
|
+
<DialogHeader>
|
|
385
|
+
<DialogTitle>{t('dialog.password.title', { username: user.username })}</DialogTitle>
|
|
386
|
+
</DialogHeader>
|
|
387
|
+
<form onSubmit={(e) => { e.preventDefault(); startTransition(async () => {
|
|
388
|
+
try {
|
|
389
|
+
await resetUserPasswordFn({ data: { userId: user.id, newPassword: passwordDialog.value } })
|
|
390
|
+
toast.success(t('toast.passwordReset'))
|
|
391
|
+
setPasswordDialog((d) => ({ ...d, open: false, value: '' }))
|
|
392
|
+
} catch { toast.error(t('toast.resetFailed')) }
|
|
393
|
+
})}}>
|
|
394
|
+
<div className="py-4">
|
|
395
|
+
<Label>{t('dialog.password.newPassword')}</Label>
|
|
396
|
+
<Input type="password" required value={passwordDialog.value} onChange={(e) => setPasswordDialog((d) => ({ ...d, value: e.target.value }))} />
|
|
397
|
+
</div>
|
|
398
|
+
<DialogFooter>
|
|
399
|
+
<Button type="button" variant="outline" onClick={() => setPasswordDialog((d) => ({ ...d, open: false }))}>{t('common:button.cancel')}</Button>
|
|
400
|
+
<Button type="submit" disabled={isPending}>{t('dialog.password.confirm')}</Button>
|
|
401
|
+
</DialogFooter>
|
|
402
|
+
</form>
|
|
403
|
+
</DialogContent>
|
|
404
|
+
</Dialog>
|
|
405
|
+
|
|
406
|
+
<AlertDialog open={deleteDialog.open} onOpenChange={(o) => setDeleteDialog((d) => ({ ...d, open: o }))}>
|
|
407
|
+
<AlertDialogContent>
|
|
408
|
+
<AlertDialogHeader>
|
|
409
|
+
<AlertDialogTitle>{t('dialog.delete.title')}</AlertDialogTitle>
|
|
410
|
+
<AlertDialogDescription>
|
|
411
|
+
{t('dialog.delete.description', { username: user.username })}
|
|
412
|
+
</AlertDialogDescription>
|
|
413
|
+
</AlertDialogHeader>
|
|
414
|
+
<Input placeholder={t('dialog.delete.placeholder')} value={deleteDialog.confirmName} onChange={(e) => setDeleteDialog((d) => ({ ...d, confirmName: e.target.value }))} />
|
|
415
|
+
<AlertDialogFooter>
|
|
416
|
+
<AlertDialogCancel>{t('common:button.cancel')}</AlertDialogCancel>
|
|
417
|
+
<AlertDialogAction
|
|
418
|
+
disabled={deleteDialog.confirmName !== user.username || isPending}
|
|
419
|
+
onClick={() => startTransition(async () => {
|
|
420
|
+
try {
|
|
421
|
+
await deleteUserFn({ data: { userId: user.id } })
|
|
422
|
+
toast.success(t('toast.userDeleted'))
|
|
423
|
+
window.location.href = '/users'
|
|
424
|
+
} catch { toast.error(t('toast.deleteFailed')) }
|
|
425
|
+
})}
|
|
426
|
+
>{t('dialog.delete.confirm')}</AlertDialogAction>
|
|
427
|
+
</AlertDialogFooter>
|
|
428
|
+
</AlertDialogContent>
|
|
429
|
+
</AlertDialog>
|
|
430
|
+
</>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { createFileRoute } from '@tanstack/react-router'
|
|
19
|
+
import { getUserDetailFn } from '@/server/serverFns/user/detail'
|
|
20
|
+
|
|
21
|
+
export const Route = createFileRoute('/_nav/users/$userId')({
|
|
22
|
+
loader: async ({ params }) => {
|
|
23
|
+
const user = await getUserDetailFn({ data: { userId: params.userId } })
|
|
24
|
+
if (!user) return { user: null, notFound: true }
|
|
25
|
+
return { user, notFound: false }
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
|
|
19
|
+
import { DataTable } from "@/components/data-table/data-table"
|
|
20
|
+
import { getUserColumns } from "@/components/user/columns"
|
|
21
|
+
import type { UserRow } from "@/components/user/columns"
|
|
22
|
+
import { useNavigate } from "@tanstack/react-router"
|
|
23
|
+
|
|
24
|
+
interface UsersTableProps {
|
|
25
|
+
users: UserRow[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function UsersTable({ users }: UsersTableProps) {
|
|
29
|
+
const navigate = useNavigate()
|
|
30
|
+
|
|
31
|
+
const handleDetail = (user: UserRow) => {
|
|
32
|
+
navigate({ to: "/users/$userId", params: { userId: user.id } })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const columns = getUserColumns(handleDetail)
|
|
36
|
+
|
|
37
|
+
return <DataTable columns={columns} data={users} onRowClick={handleDetail} />
|
|
38
|
+
}
|