@ploykit/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +40 -2
  2. package/dist/components/ConfirmDialog.d.ts +13 -0
  3. package/dist/components/ConfirmDialog.js +29 -0
  4. package/dist/components/CronInput.d.ts +15 -0
  5. package/dist/components/CronInput.js +112 -0
  6. package/dist/components/DataTable.d.ts +22 -0
  7. package/dist/components/DataTable.js +13 -0
  8. package/dist/components/ErrorBoundary.d.ts +16 -0
  9. package/dist/components/ErrorBoundary.js +24 -0
  10. package/dist/components/FormField.d.ts +10 -0
  11. package/dist/components/FormField.js +22 -0
  12. package/dist/components/ImpersonationBanner.d.ts +3 -0
  13. package/dist/components/ImpersonationBanner.js +10 -0
  14. package/dist/components/NotificationBell.d.ts +3 -0
  15. package/dist/components/NotificationBell.js +47 -0
  16. package/dist/components/OnboardingChecklist.d.ts +12 -0
  17. package/dist/components/OnboardingChecklist.js +43 -0
  18. package/dist/components/Page.d.ts +17 -0
  19. package/dist/components/Page.js +16 -0
  20. package/dist/components/SecretModal.d.ts +16 -0
  21. package/dist/components/SecretModal.js +45 -0
  22. package/dist/components/SettingsCard.d.ts +10 -0
  23. package/dist/components/SettingsCard.js +6 -0
  24. package/dist/components/SiteBanner.d.ts +6 -0
  25. package/dist/components/SiteBanner.js +11 -0
  26. package/dist/components/SiteBannerLayer.d.ts +4 -0
  27. package/dist/components/SiteBannerLayer.js +9 -0
  28. package/dist/components/UserMenu.d.ts +13 -0
  29. package/dist/components/UserMenu.js +30 -0
  30. package/dist/components/WorkspaceSwitcher.d.ts +1 -0
  31. package/dist/components/WorkspaceSwitcher.js +53 -0
  32. package/dist/components/toast.d.ts +7 -0
  33. package/dist/components/toast.js +11 -0
  34. package/dist/components/ui/Badge.d.ts +13 -0
  35. package/dist/components/ui/Badge.js +21 -0
  36. package/dist/components/ui/Button.d.ts +10 -0
  37. package/dist/components/ui/Button.js +33 -0
  38. package/dist/components/ui/Input.d.ts +3 -0
  39. package/dist/components/ui/Input.js +10 -0
  40. package/dist/components/ui/alert-dialog.d.ts +18 -0
  41. package/dist/components/ui/alert-dialog.js +41 -0
  42. package/dist/components/ui/avatar.d.ts +11 -0
  43. package/dist/components/ui/avatar.js +22 -0
  44. package/dist/components/ui/breadcrumb.d.ts +10 -0
  45. package/dist/components/ui/breadcrumb.js +36 -0
  46. package/dist/components/ui/card.d.ts +13 -0
  47. package/dist/components/ui/card.js +24 -0
  48. package/dist/components/ui/checkbox.d.ts +3 -0
  49. package/dist/components/ui/checkbox.js +8 -0
  50. package/dist/components/ui/dialog.d.ts +15 -0
  51. package/dist/components/ui/dialog.js +26 -0
  52. package/dist/components/ui/empty.d.ts +13 -0
  53. package/dist/components/ui/empty.js +36 -0
  54. package/dist/components/ui/field.d.ts +24 -0
  55. package/dist/components/ui/field.js +67 -0
  56. package/dist/components/ui/label.d.ts +3 -0
  57. package/dist/components/ui/label.js +6 -0
  58. package/dist/components/ui/menu.d.ts +29 -0
  59. package/dist/components/ui/menu.js +50 -0
  60. package/dist/components/ui/popover.d.ts +9 -0
  61. package/dist/components/ui/popover.js +22 -0
  62. package/dist/components/ui/progress.d.ts +7 -0
  63. package/dist/components/ui/progress.js +19 -0
  64. package/dist/components/ui/select.d.ts +15 -0
  65. package/dist/components/ui/select.js +33 -0
  66. package/dist/components/ui/separator.d.ts +3 -0
  67. package/dist/components/ui/separator.js +7 -0
  68. package/dist/components/ui/sheet.d.ts +14 -0
  69. package/dist/components/ui/sheet.js +36 -0
  70. package/dist/components/ui/sidebar.d.ts +63 -0
  71. package/dist/components/ui/sidebar.js +265 -0
  72. package/dist/components/ui/skeleton.d.ts +3 -0
  73. package/dist/components/ui/skeleton.js +6 -0
  74. package/dist/components/ui/switch.d.ts +5 -0
  75. package/dist/components/ui/switch.js +7 -0
  76. package/dist/components/ui/tabs.d.ts +10 -0
  77. package/dist/components/ui/tabs.js +28 -0
  78. package/dist/components/ui/tooltip.d.ts +6 -0
  79. package/dist/components/ui/tooltip.js +16 -0
  80. package/dist/hooks/use-mobile.d.ts +1 -0
  81. package/dist/hooks/use-mobile.js +15 -0
  82. package/dist/hooks/useApi.d.ts +10 -0
  83. package/dist/hooks/useApi.js +11 -0
  84. package/dist/hooks/useAudit.d.ts +39 -0
  85. package/dist/hooks/useAudit.js +55 -0
  86. package/dist/hooks/useAuth.d.ts +1 -0
  87. package/dist/hooks/useAuth.js +4 -0
  88. package/dist/hooks/useBilling.d.ts +44 -0
  89. package/dist/hooks/useBilling.js +64 -0
  90. package/dist/hooks/useLogin.d.ts +10 -0
  91. package/dist/hooks/useLogin.js +39 -0
  92. package/dist/hooks/useMembers.d.ts +125 -0
  93. package/dist/hooks/useMembers.js +127 -0
  94. package/dist/hooks/useNotificationPrefs.d.ts +20 -0
  95. package/dist/hooks/useNotificationPrefs.js +42 -0
  96. package/dist/hooks/usePasswordReset.d.ts +5 -0
  97. package/dist/hooks/usePasswordReset.js +12 -0
  98. package/dist/hooks/useSEO.d.ts +6 -0
  99. package/dist/hooks/useSEO.js +14 -0
  100. package/dist/hooks/useSessions.d.ts +20 -0
  101. package/dist/hooks/useSessions.js +38 -0
  102. package/dist/hooks/useSiteConfig.d.ts +12 -0
  103. package/dist/hooks/useSiteConfig.js +11 -0
  104. package/dist/hooks/useTokens.d.ts +36 -0
  105. package/dist/hooks/useTokens.js +35 -0
  106. package/dist/hooks/useUsage.d.ts +20 -0
  107. package/dist/hooks/useUsage.js +21 -0
  108. package/dist/hooks/useWorkspace.d.ts +26 -0
  109. package/dist/hooks/useWorkspace.js +32 -0
  110. package/dist/hooks/useZodForm.d.ts +6 -0
  111. package/dist/hooks/useZodForm.js +6 -0
  112. package/dist/hooks/ws.d.ts +4 -0
  113. package/dist/hooks/ws.js +21 -0
  114. package/dist/index.d.ts +124 -0
  115. package/dist/index.js +94 -0
  116. package/dist/layouts/AppShell.d.ts +20 -0
  117. package/dist/layouts/AppShell.js +21 -0
  118. package/dist/layouts/AuthShell.d.ts +7 -0
  119. package/dist/layouts/AuthShell.js +4 -0
  120. package/dist/layouts/SettingsShell.d.ts +19 -0
  121. package/dist/layouts/SettingsShell.js +21 -0
  122. package/dist/lib/api-error.d.ts +1 -0
  123. package/dist/lib/api-error.js +74 -0
  124. package/dist/lib/perm.d.ts +2 -0
  125. package/dist/lib/perm.js +7 -0
  126. package/dist/lib/utils.d.ts +12 -0
  127. package/dist/lib/utils.js +84 -0
  128. package/dist/lib/ws-query-bridge.d.ts +7 -0
  129. package/dist/lib/ws-query-bridge.js +35 -0
  130. package/dist/pages/LandingPage.d.ts +22 -0
  131. package/dist/pages/LandingPage.js +5 -0
  132. package/dist/pages/LoginPage.d.ts +15 -0
  133. package/dist/pages/LoginPage.js +87 -0
  134. package/dist/pages/account/NotificationPreferencesPage.d.ts +13 -0
  135. package/dist/pages/account/NotificationPreferencesPage.js +41 -0
  136. package/dist/pages/account/ProfileSettingsPage.d.ts +4 -0
  137. package/dist/pages/account/ProfileSettingsPage.js +96 -0
  138. package/dist/pages/account/SecuritySettingsPage.d.ts +1 -0
  139. package/dist/pages/account/SecuritySettingsPage.js +134 -0
  140. package/dist/pages/account/TokensPage.d.ts +1 -0
  141. package/dist/pages/account/TokensPage.js +117 -0
  142. package/dist/pages/admin/AdminSettingsPage.d.ts +1 -0
  143. package/dist/pages/admin/AdminSettingsPage.js +210 -0
  144. package/dist/pages/admin/AdminShell.d.ts +7 -0
  145. package/dist/pages/admin/AdminShell.js +79 -0
  146. package/dist/pages/admin/AuditPage.d.ts +1 -0
  147. package/dist/pages/admin/AuditPage.js +164 -0
  148. package/dist/pages/admin/OrdersPage.d.ts +1 -0
  149. package/dist/pages/admin/OrdersPage.js +236 -0
  150. package/dist/pages/admin/OverviewPage.d.ts +1 -0
  151. package/dist/pages/admin/OverviewPage.js +200 -0
  152. package/dist/pages/admin/SSOPage.d.ts +1 -0
  153. package/dist/pages/admin/SSOPage.js +53 -0
  154. package/dist/pages/admin/UserDetailPage.d.ts +1 -0
  155. package/dist/pages/admin/UserDetailPage.js +266 -0
  156. package/dist/pages/admin/UsersPage.d.ts +4 -0
  157. package/dist/pages/admin/UsersPage.js +201 -0
  158. package/dist/pages/admin/WebhookDeliveriesPage.d.ts +1 -0
  159. package/dist/pages/admin/WebhookDeliveriesPage.js +128 -0
  160. package/dist/pages/admin/WorkspaceDetailPage.d.ts +1 -0
  161. package/dist/pages/admin/WorkspaceDetailPage.js +202 -0
  162. package/dist/pages/admin/WorkspacesPage.d.ts +1 -0
  163. package/dist/pages/admin/WorkspacesPage.js +133 -0
  164. package/dist/pages/admin/shared.d.ts +8 -0
  165. package/dist/pages/admin/shared.js +23 -0
  166. package/dist/pages/auth/ForgotPasswordPage.d.ts +6 -0
  167. package/dist/pages/auth/ForgotPasswordPage.js +32 -0
  168. package/dist/pages/auth/InviteAcceptPage.d.ts +5 -0
  169. package/dist/pages/auth/InviteAcceptPage.js +40 -0
  170. package/dist/pages/auth/RegisterPage.d.ts +15 -0
  171. package/dist/pages/auth/RegisterPage.js +80 -0
  172. package/dist/pages/auth/ResetPasswordPage.d.ts +4 -0
  173. package/dist/pages/auth/ResetPasswordPage.js +71 -0
  174. package/dist/pages/auth/VerifyEmailPage.d.ts +1 -0
  175. package/dist/pages/auth/VerifyEmailPage.js +45 -0
  176. package/dist/pages/workspace/BillingPage.d.ts +4 -0
  177. package/dist/pages/workspace/BillingPage.js +183 -0
  178. package/dist/pages/workspace/BillingSuccessPage.d.ts +4 -0
  179. package/dist/pages/workspace/BillingSuccessPage.js +72 -0
  180. package/dist/pages/workspace/MembersPage.d.ts +3 -0
  181. package/dist/pages/workspace/MembersPage.js +262 -0
  182. package/dist/pages/workspace/SchedulesPage.d.ts +4 -0
  183. package/dist/pages/workspace/SchedulesPage.js +177 -0
  184. package/dist/pages/workspace/UsagePage.d.ts +15 -0
  185. package/dist/pages/workspace/UsagePage.js +74 -0
  186. package/dist/pages/workspace/WebhooksPage.d.ts +3 -0
  187. package/dist/pages/workspace/WebhooksPage.js +242 -0
  188. package/dist/pages/workspace/WorkspaceAuditPage.d.ts +3 -0
  189. package/dist/pages/workspace/WorkspaceAuditPage.js +109 -0
  190. package/dist/pages/workspace/WorkspaceGeneralPage.d.ts +4 -0
  191. package/dist/pages/workspace/WorkspaceGeneralPage.js +101 -0
  192. package/dist/provider/PloykitProvider.d.ts +50 -0
  193. package/dist/provider/PloykitProvider.js +240 -0
  194. package/dist/realtime.d.ts +3 -0
  195. package/dist/realtime.js +11 -0
  196. package/package.json +61 -4
@@ -0,0 +1,22 @@
1
+ export interface Feature {
2
+ icon: string;
3
+ title: string;
4
+ desc: string;
5
+ }
6
+ export interface Plan {
7
+ name: string;
8
+ price: string;
9
+ period?: string;
10
+ recommended?: boolean;
11
+ features: string[];
12
+ cta: string;
13
+ }
14
+ export declare function LandingPage({ brand, tagline, description, features, plans, registerPath, loginPath }: {
15
+ brand: string;
16
+ tagline: string;
17
+ description: string;
18
+ features: Feature[];
19
+ plans: Plan[];
20
+ registerPath?: string;
21
+ loginPath?: string;
22
+ }): import("react").JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Link } from 'react-router';
3
+ export function LandingPage({ brand, tagline, description, features, plans, registerPath = '/register', loginPath = '/login' }) {
4
+ return (_jsxs("div", { className: "min-h-screen bg-white", children: [_jsx("header", { className: "border-b border-gray-100", children: _jsxs("div", { className: "max-w-6xl mx-auto px-4 h-16 flex items-center justify-between", children: [_jsx("span", { className: "text-xl font-bold text-gray-900", children: brand }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Link, { to: loginPath, className: "px-4 py-2 text-sm font-medium text-gray-700 rounded-lg hover:bg-gray-50", children: "\u767B\u5F55" }), _jsx(Link, { to: registerPath, className: "px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700", children: "\u514D\u8D39\u6CE8\u518C" })] })] }) }), _jsxs("section", { className: "max-w-6xl mx-auto px-4 pt-20 pb-16 text-center", children: [_jsx("h1", { className: "text-5xl font-bold text-gray-900 tracking-tight", children: tagline }), _jsx("p", { className: "mt-4 text-xl text-gray-500 max-w-2xl mx-auto", children: description }), _jsx(Link, { to: registerPath, className: "mt-8 inline-block px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 text-lg", children: "\u514D\u8D39\u5F00\u59CB" })] }), _jsx("section", { className: "bg-gray-50 py-16", children: _jsxs("div", { className: "max-w-6xl mx-auto px-4", children: [_jsx("h2", { className: "text-3xl font-bold text-center text-gray-900 mb-12", children: "\u6838\u5FC3\u529F\u80FD" }), _jsx("div", { className: "grid md:grid-cols-3 gap-8", children: features.map((f, i) => (_jsxs("div", { className: "p-6 bg-white rounded-xl border border-gray-200", children: [_jsx("div", { className: "text-3xl mb-3", children: f.icon }), _jsx("h3", { className: "text-lg font-semibold text-gray-900", children: f.title }), _jsx("p", { className: "mt-2 text-sm text-gray-500 leading-relaxed", children: f.desc })] }, `${i}-${f.title}`))) })] }) }), _jsx("section", { className: "py-16", children: _jsxs("div", { className: "max-w-6xl mx-auto px-4", children: [_jsx("h2", { className: "text-3xl font-bold text-center text-gray-900 mb-12", children: "\u5B9A\u4EF7" }), _jsx("div", { className: "grid md:grid-cols-2 gap-8 max-w-3xl mx-auto", children: plans.map((p, i) => (_jsxs("div", { className: `p-8 bg-white rounded-xl ${p.recommended ? 'border-2 border-blue-600 relative' : 'border border-gray-200'}`, children: [p.recommended && (_jsx("span", { className: "absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-blue-600 text-white text-xs font-medium rounded-full", children: "\u63A8\u8350" })), _jsx("h3", { className: "text-lg font-semibold text-gray-900", children: p.name }), _jsxs("p", { className: "mt-1 text-3xl font-bold text-gray-900", children: [p.price, _jsx("span", { className: "text-sm text-gray-400", children: p.period ?? '/月' })] }), _jsx("ul", { className: "mt-6 space-y-3 text-sm text-gray-600", children: p.features.map((f, j) => _jsxs("li", { children: ["\u2713 ", f] }, `${j}-${f}`)) }), _jsx(Link, { to: registerPath, className: `mt-6 block text-center py-2.5 rounded-lg font-medium ${p.recommended ? 'bg-blue-600 text-white hover:bg-blue-700' : 'border border-gray-300 text-gray-700 hover:bg-gray-50'}`, children: p.cta })] }, `${i}-${p.name}`))) })] }) }), _jsxs("footer", { className: "border-t border-gray-100 py-8 text-center text-sm text-gray-400", children: ["\u00A9 2026 ", brand, ". All rights reserved."] })] }));
5
+ }
@@ -0,0 +1,15 @@
1
+ export interface OAuthProviderOption {
2
+ id: string;
3
+ label: string;
4
+ }
5
+ export declare function loginRedirectTarget(from: unknown, fallback?: string): string;
6
+ export interface LoginPageProps {
7
+ resetPath?: string;
8
+ registerPath?: string;
9
+ oauthProviders?: OAuthProviderOption[];
10
+ enableFedLogin?: boolean;
11
+ onSuccess?: () => void;
12
+ title?: string;
13
+ description?: string;
14
+ }
15
+ export declare function LoginPage({ resetPath, registerPath, oauthProviders, enableFedLogin, onSuccess, title, description, }: LoginPageProps): import("react").JSX.Element;
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useLocation } from 'react-router';
4
+ import { useLogin } from '../hooks/useLogin';
5
+ import { useApi } from '../hooks/useApi';
6
+ import { useAuth } from '../hooks/useAuth';
7
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/tabs';
8
+ import { Button } from '../components/ui/Button';
9
+ import { Input } from '../components/ui/Input';
10
+ import { FormField } from '../components/FormField';
11
+ import { apiErrorMessage } from '../lib/api-error';
12
+ const SEND_COOLDOWN_S = 60;
13
+ export function loginRedirectTarget(from, fallback = '/app') {
14
+ if (typeof from !== 'string')
15
+ return fallback;
16
+ if (!from.startsWith('/') || from.startsWith('//'))
17
+ return fallback;
18
+ return from;
19
+ }
20
+ export function LoginPage({ resetPath = '/forgot-password', registerPath = '/register', oauthProviders = [], enableFedLogin = false, onSuccess, title = '登录', description = '登录你的账号', }) {
21
+ const { submit, loading, error: codeError } = useLogin();
22
+ const api = useApi();
23
+ const { refresh } = useAuth();
24
+ const from = loginRedirectTarget(useLocation().state?.from);
25
+ const [email, setEmail] = useState('');
26
+ const [code, setCode] = useState('');
27
+ const [codeSent, setCodeSent] = useState(false);
28
+ const [sending, setSending] = useState(false);
29
+ const [sendError, setSendError] = useState(null);
30
+ const [cooldown, setCooldown] = useState(0);
31
+ useEffect(() => {
32
+ if (cooldown <= 0)
33
+ return;
34
+ const t = setInterval(() => setCooldown((c) => c - 1), 1_000);
35
+ return () => clearInterval(t);
36
+ }, [cooldown]);
37
+ const [password, setPassword] = useState('');
38
+ const [pwLoading, setPwLoading] = useState(false);
39
+ const [pwError, setPwError] = useState(null);
40
+ const succeed = onSuccess ?? (() => { window.location.href = from; });
41
+ const handleSendCode = async () => {
42
+ if (!email || sending || cooldown > 0)
43
+ return;
44
+ setSending(true);
45
+ setSendError(null);
46
+ try {
47
+ await api.post('/auth/send-code', { email });
48
+ setCodeSent(true);
49
+ setCooldown(SEND_COOLDOWN_S);
50
+ }
51
+ catch (e) {
52
+ setSendError(apiErrorMessage(e, '验证码发送失败,请稍后重试'));
53
+ }
54
+ finally {
55
+ setSending(false);
56
+ }
57
+ };
58
+ const handleCodeSubmit = async (e) => {
59
+ e.preventDefault();
60
+ const result = await submit(email, code);
61
+ if (result.ok)
62
+ succeed();
63
+ };
64
+ const handlePasswordSubmit = async (e) => {
65
+ e.preventDefault();
66
+ setPwError(null);
67
+ setPwLoading(true);
68
+ try {
69
+ await api.post('/auth/login', { email, password });
70
+ await refresh();
71
+ succeed();
72
+ }
73
+ catch (err) {
74
+ setPwError(apiErrorMessage(err, '登录失败'));
75
+ }
76
+ finally {
77
+ setPwLoading(false);
78
+ }
79
+ };
80
+ return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-gray-50", children: _jsxs("div", { className: "w-full max-w-sm space-y-4 p-8 bg-white rounded-lg shadow", children: [_jsxs("div", { className: "text-center", children: [_jsx("h1", { className: "text-2xl font-bold", children: title }), _jsx("p", { className: "mt-1 text-sm text-gray-500", children: description })] }), _jsxs(Tabs, { defaultValue: "code", children: [_jsxs(TabsList, { className: "w-full", children: [_jsx(TabsTrigger, { value: "code", children: "\u9A8C\u8BC1\u7801\u767B\u5F55" }), _jsx(TabsTrigger, { value: "password", children: "\u5BC6\u7801\u767B\u5F55" })] }), _jsx(TabsContent, { value: "code", children: _jsxs("form", { onSubmit: handleCodeSubmit, className: "space-y-4 pt-2", children: [_jsxs("div", { className: "space-y-1", children: [_jsx("label", { htmlFor: "login-email", className: "text-sm text-gray-600", children: "\u90AE\u7BB1" }), _jsx("input", { id: "login-email", type: "email", value: email, onChange: (e) => setEmail(e.target.value), placeholder: "you@example.com", required: true, autoComplete: "email", className: "w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" })] }), _jsxs("div", { className: "space-y-1", children: [_jsx("label", { htmlFor: "login-code", className: "text-sm text-gray-600", children: "\u9A8C\u8BC1\u7801" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { id: "login-code", type: "text", value: code, onChange: (e) => setCode(e.target.value), placeholder: "6\u4F4D\u6570\u5B57", maxLength: 6, required: true, autoComplete: "one-time-code", className: "flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" }), _jsx("button", { type: "button", onClick: handleSendCode, disabled: sending || cooldown > 0 || !email, className: "px-4 py-2 text-sm text-blue-600 border border-blue-300 rounded-md hover:bg-blue-50 disabled:opacity-50", children: cooldown > 0
81
+ ? `重发(${cooldown}s)`
82
+ : codeSent
83
+ ? '重新发送'
84
+ : sending
85
+ ? '发送中…'
86
+ : '发送' })] })] }), sendError && _jsx("p", { role: "alert", className: "text-red-500 text-sm", children: sendError }), codeError && _jsx("p", { role: "alert", className: "text-red-500 text-sm", children: codeError }), _jsx("button", { type: "submit", disabled: loading || !email || !code, className: "w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50", children: loading ? '登录中…' : '登录' })] }) }), _jsx(TabsContent, { value: "password", children: _jsxs("form", { onSubmit: handlePasswordSubmit, className: "space-y-4 pt-2", children: [_jsx(FormField, { label: "\u90AE\u7BB1", htmlFor: "login-pw-email", children: _jsx(Input, { id: "login-pw-email", type: "email", value: email, required: true, onChange: (e) => setEmail(e.target.value), placeholder: "you@example.com", autoComplete: "email" }) }), _jsx(FormField, { label: "\u5BC6\u7801", htmlFor: "login-pw-password", children: _jsx(Input, { id: "login-pw-password", type: "password", value: password, required: true, onChange: (e) => setPassword(e.target.value), placeholder: "\u8F93\u5165\u5BC6\u7801", autoComplete: "current-password" }) }), pwError && _jsx("p", { role: "alert", className: "text-red-500 text-sm", children: pwError }), _jsx(Button, { type: "submit", className: "w-full", disabled: pwLoading || !email || !password, children: pwLoading ? '登录中…' : '登录' })] }) })] }), oauthProviders.length > 0 && (_jsxs("div", { className: "space-y-2 pt-1", "data-testid": "oauth-providers", children: [_jsxs("div", { className: "relative py-2 text-center", children: [_jsx("span", { className: "absolute inset-x-0 top-1/2 border-t border-gray-200" }), _jsx("span", { className: "relative z-10 bg-white px-2 text-xs text-gray-400", children: "\u6216\u4F7F\u7528\u4EE5\u4E0B\u65B9\u5F0F\u767B\u5F55" })] }), oauthProviders.map((p) => (_jsxs("a", { href: '/auth/oauth/' + p.id + '/start', className: "block w-full py-2 px-4 text-center text-sm border border-gray-300 rounded-md hover:bg-gray-50", children: ["\u4F7F\u7528 ", p.label, " \u767B\u5F55"] }, p.id)))] })), enableFedLogin && (_jsxs("details", { className: "pt-1", "data-testid": "fed-login", children: [_jsx("summary", { className: "text-sm text-gray-500 cursor-pointer select-none", children: "\u4F01\u4E1A\u767B\u5F55" }), _jsxs("form", { method: "get", action: "/auth/fed/start", className: "mt-2 space-y-2", children: [_jsx("input", { type: "text", name: "workspace", placeholder: "\u5DE5\u4F5C\u533A\u6807\u8BC6\uFF08slug\uFF09", required: true, className: "w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" }), _jsx("button", { type: "submit", className: "w-full py-2 px-4 bg-gray-800 text-white text-sm rounded-md hover:bg-gray-900", children: "\u901A\u8FC7\u4F01\u4E1A IdP \u767B\u5F55" })] })] })), _jsxs("div", { className: "flex items-center justify-between text-sm", children: [_jsx("a", { href: resetPath, className: "text-blue-600 hover:underline", children: "\u5FD8\u8BB0\u5BC6\u7801\uFF1F" }), registerPath && (_jsx("a", { href: registerPath, "data-testid": "register-link", className: "text-blue-600 hover:underline", children: "\u521B\u5EFA\u8D26\u53F7" }))] })] }) }));
87
+ }
@@ -0,0 +1,13 @@
1
+ export interface NotificationTypeMeta {
2
+ type: string;
3
+ label: string;
4
+ description?: string;
5
+ }
6
+ export interface NotificationPreferencesPageProps {
7
+ types?: NotificationTypeMeta[];
8
+ title?: string;
9
+ description?: string;
10
+ siteName?: string;
11
+ emailChannelNote?: string;
12
+ }
13
+ export declare function NotificationPreferencesPage({ types, title, description, siteName, emailChannelNote, }: NotificationPreferencesPageProps): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { PageHeader, PageLoading, PageError, PageEmpty } from '../../components/Page';
4
+ import { Switch } from '../../components/ui/switch';
5
+ import { toast } from '../../components/toast';
6
+ import { useSEO } from '../../hooks/useSEO';
7
+ import { useNotificationPrefs, useSetNotificationPref, } from '../../hooks/useNotificationPrefs';
8
+ export function NotificationPreferencesPage({ types, title = '通知偏好', description = '选择每种通知的接收渠道;未显示的类型默认全部开启', siteName, emailChannelNote, }) {
9
+ const { prefs, loading, error, refetch } = useNotificationPrefs();
10
+ const { setPref } = useSetNotificationPref();
11
+ const uid = useId();
12
+ useSEO({ title: siteName ? `${title} · ${siteName}` : title });
13
+ if (loading)
14
+ return _jsxs("div", { children: [_jsx(PageHeader, { title: title, description: description }), _jsx(PageLoading, {})] });
15
+ if (error) {
16
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: title, description: description }), _jsx(PageError, { message: error instanceof Error ? error.message : '偏好加载失败', onRetry: () => refetch() })] }));
17
+ }
18
+ const byType = new Map(prefs.map((p) => [p.notification_type, p]));
19
+ const list = types ?? prefs.map((p) => ({ type: p.notification_type, label: p.notification_type }));
20
+ if (list.length === 0) {
21
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: title, description: description }), _jsx(PageEmpty, { label: "\u6682\u65E0\u53EF\u914D\u7F6E\u7684\u901A\u77E5\u7C7B\u578B", hint: "\u901A\u77E5\u7C7B\u578B\u76EE\u5F55\u7531\u670D\u52A1\u7AEF\u4E0B\u53D1\uFF1A\u9996\u6B21\u4EA7\u751F\u901A\u77E5\u8BBE\u7F6E\uFF08\u6216\u540E\u7AEF\u63A5\u5165\u7C7B\u578B\u76EE\u5F55\u63A5\u53E3\uFF09\u540E\uFF0C\u4F1A\u51FA\u73B0\u5728\u8FD9\u91CC\uFF1B\u9ED8\u8BA4\u6240\u6709\u901A\u77E5\u53CC\u6E20\u9053\u5F00\u542F\u3002" })] }));
22
+ }
23
+ const toggle = async (type, channel, next) => {
24
+ try {
25
+ await setPref(type, { [channel]: next });
26
+ }
27
+ catch (e) {
28
+ toast.error(e instanceof Error ? e.message : '保存失败');
29
+ }
30
+ };
31
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: title, description: description }), _jsxs("div", { className: "w-full max-w-4xl space-y-4", children: [emailChannelNote && (_jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u90AE\u4EF6\u6E20\u9053\uFF1A", emailChannelNote] })), list.map(({ type, label, description: desc }) => {
32
+ const row = byType.get(type);
33
+ const inApp = row?.in_app_enabled ?? true;
34
+ const email = row?.email_enabled ?? true;
35
+ const inAppInputId = `${uid}-${type}-in-app`;
36
+ const emailInputId = `${uid}-${type}-email`;
37
+ const inAppLabelId = `${inAppInputId}-label`;
38
+ const emailLabelId = `${emailInputId}-label`;
39
+ return (_jsxs("div", { className: "flex flex-row items-center justify-between gap-4 rounded-lg border p-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm font-medium", children: label }), desc && _jsx("p", { className: "mt-0.5 text-sm text-muted-foreground", children: desc })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-5", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsxs("label", { id: inAppLabelId, htmlFor: inAppInputId, className: "cursor-pointer select-none", children: ["\u7AD9\u5185", _jsxs("span", { className: "sr-only", children: ["\uFF08", label, "\uFF09"] })] }), _jsx(Switch, { id: inAppInputId, "aria-labelledby": inAppLabelId, checked: inApp, onCheckedChange: (v) => toggle(type, 'in_app_enabled', v) })] }), _jsxs("div", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [_jsxs("label", { id: emailLabelId, htmlFor: emailInputId, className: "cursor-pointer select-none", children: ["\u90AE\u4EF6", _jsxs("span", { className: "sr-only", children: ["\uFF08", label, "\uFF09"] })] }), _jsx(Switch, { id: emailInputId, "aria-labelledby": emailLabelId, checked: email, onCheckedChange: (v) => toggle(type, 'email_enabled', v) })] })] })] }, type));
40
+ })] })] }));
41
+ }
@@ -0,0 +1,4 @@
1
+ export interface ProfileSettingsPageProps {
2
+ onDeleted?: () => void;
3
+ }
4
+ export declare function ProfileSettingsPage({ onDeleted }: ProfileSettingsPageProps): import("react").JSX.Element;
@@ -0,0 +1,96 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { z } from 'zod';
4
+ import { PageHeader, PageLoading, PageError } from '../../components/Page';
5
+ import { SettingsCard } from '../../components/SettingsCard';
6
+ import { FormField } from '../../components/FormField';
7
+ import { Button } from '../../components/ui/Button';
8
+ import { Input } from '../../components/ui/Input';
9
+ import { Badge } from '../../components/ui/Badge';
10
+ import { ImpactConfirmation } from '../../components/SecretModal';
11
+ import { toast } from '../../components/toast';
12
+ import { apiErrorMessage } from '../../lib/api-error';
13
+ import { useApi } from '../../hooks/useApi';
14
+ import { useAuth } from '../../hooks/useAuth';
15
+ import { useZodForm } from '../../hooks/useZodForm';
16
+ const profileSchema = z.object({
17
+ display_name: z.string().trim().min(1, '请输入昵称').max(100, '昵称最多 100 字符'),
18
+ avatar_url: z.string()
19
+ .refine((v) => v === '' || /^https?:\/\//.test(v), '头像链接需以 http(s):// 开头')
20
+ .refine((v) => v === '' || v.length <= 2048, '头像链接最多 2048 字符'),
21
+ });
22
+ export function ProfileSettingsPage({ onDeleted }) {
23
+ const { user, loading, refresh, logout } = useAuth();
24
+ const api = useApi();
25
+ const [saving, setSaving] = useState(false);
26
+ const [sending, setSending] = useState(false);
27
+ const [deleting, setDeleting] = useState(false);
28
+ const [confirmEmail, setConfirmEmail] = useState('');
29
+ const [confirmOpen, setConfirmOpen] = useState(false);
30
+ const form = useZodForm({
31
+ schema: profileSchema,
32
+ defaultValues: { display_name: user?.display_name ?? '', avatar_url: user?.avatar_url ?? '' },
33
+ values: user
34
+ ? { display_name: user.display_name, avatar_url: user.avatar_url ?? '' }
35
+ : undefined, // 用户数据到达后同步进表单(保留已输入内容的 RHF 回退语义)
36
+ });
37
+ if (loading)
38
+ return _jsx(PageLoading, { label: "\u52A0\u8F7D\u8D26\u53F7\u4FE1\u606F\u2026" });
39
+ if (!user) {
40
+ return _jsx(PageError, { message: "\u672A\u767B\u5F55\u6216\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55", onRetry: () => refresh() });
41
+ }
42
+ const handleSave = form.handleSubmit(async (values) => {
43
+ setSaving(true);
44
+ try {
45
+ await api.patch('/auth/me', {
46
+ display_name: values.display_name,
47
+ avatar_url: values.avatar_url,
48
+ });
49
+ await refresh();
50
+ toast.success('资料已更新');
51
+ }
52
+ catch (e) {
53
+ toast.error(apiErrorMessage(e, '保存失败'));
54
+ }
55
+ finally {
56
+ setSaving(false);
57
+ }
58
+ });
59
+ const handleResend = async () => {
60
+ setSending(true);
61
+ try {
62
+ await api.post('/auth/send-verification');
63
+ toast.success('验证邮件已发送,请查收');
64
+ }
65
+ catch (e) {
66
+ toast.error(apiErrorMessage(e, '发送失败'));
67
+ }
68
+ finally {
69
+ setSending(false);
70
+ }
71
+ };
72
+ const handleDelete = async () => {
73
+ setDeleting(true);
74
+ try {
75
+ await api.delete('/auth/me');
76
+ if (onDeleted)
77
+ onDeleted();
78
+ else {
79
+ await logout();
80
+ window.location.href = '/login';
81
+ }
82
+ }
83
+ catch (e) {
84
+ toast.error(apiErrorMessage(e, '注销失败'));
85
+ }
86
+ finally {
87
+ setDeleting(false);
88
+ }
89
+ };
90
+ const emailReadyToDelete = confirmEmail.trim().toLowerCase() === user.email.toLowerCase();
91
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u4E2A\u4EBA\u8D44\u6599", description: "\u7BA1\u7406\u6635\u79F0\u3001\u5934\u50CF\u7B49\u8D26\u53F7\u8D44\u6599" }), _jsxs(SettingsCard, { title: "\u8D44\u6599", description: "\u5BF9\u5916\u5C55\u793A\u7684\u6635\u79F0\u4E0E\u5934\u50CF", cta: _jsx(Button, { type: "submit", form: "profile-form", disabled: saving, children: saving ? '保存中…' : '保存' }), children: [_jsxs("form", { id: "profile-form", onSubmit: handleSave, className: "max-w-xl space-y-4", children: [_jsx(FormField, { label: "\u6635\u79F0", required: true, error: form.formState.errors.display_name?.message, htmlFor: "profile-name", children: _jsx(Input, { id: "profile-name", maxLength: 100, placeholder: "\u600E\u4E48\u79F0\u547C\u4F60", ...form.register('display_name') }) }), _jsx(FormField, { label: "\u5934\u50CF\u94FE\u63A5", error: form.formState.errors.avatar_url?.message, htmlFor: "profile-avatar", description: "\u53EF\u9009\uFF1B\u9700 http(s):// \u5F00\u5934\u3001\u6700\u591A 2048 \u5B57\u7B26\u3002\u5916\u90E8\u56FE\u7247\u53EF\u80FD\u88AB\u5185\u5BB9\u5B89\u5168\u7B56\u7565\u62E6\u622A\uFF0C\u65E0\u6CD5\u52A0\u8F7D\u65F6\u663E\u793A\u9ED8\u8BA4\u5934\u50CF", children: _jsx(Input, { id: "profile-avatar", maxLength: 2048, placeholder: "https://\u2026\uFF08\u53EF\u9009\uFF09", ...form.register('avatar_url') }) })] }), _jsxs("div", { className: "mt-6 flex flex-row items-center justify-between gap-4 rounded-lg border p-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm font-medium", children: "\u767B\u5F55\u90AE\u7BB1" }), _jsx("p", { className: "mt-0.5 truncate text-sm text-muted-foreground", "data-testid": "profile-email", children: user.email }), _jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: "\u7528\u4E8E\u767B\u5F55\u4E0E\u63A5\u6536\u901A\u77E5\uFF0C\u6682\u4E0D\u652F\u6301\u81EA\u52A9\u4FEE\u6539" })] }), _jsx("div", { className: "flex shrink-0 items-center gap-2", children: user.email_verified ? (_jsx(Badge, { variant: "secondary", className: "bg-emerald-50 text-emerald-700 border-emerald-200", children: "\u5DF2\u9A8C\u8BC1" })) : (_jsxs(_Fragment, { children: [_jsx(Badge, { variant: "outline", className: "text-amber-700 border-amber-200 bg-amber-50", children: "\u672A\u9A8C\u8BC1" }), _jsx(Button, { variant: "outline", size: "sm", onClick: handleResend, disabled: sending, children: sending ? '发送中…' : '重新发送验证邮件' })] })) })] })] }), _jsx(SettingsCard, { danger: true, title: "\u5220\u9664\u8D26\u53F7", description: "\u6CE8\u9500\u540E\u8D26\u53F7\u8FDB\u5165\u5220\u9664\u72B6\u6001\uFF0C\u65E0\u6CD5\u6062\u590D", cta: _jsx(Button, { variant: "destructive", disabled: !emailReadyToDelete || deleting, onClick: () => setConfirmOpen(true), children: "\u5220\u9664\u8D26\u53F7" }), children: _jsx("div", { className: "max-w-xl space-y-4", children: _jsx(FormField, { label: "\u8F93\u5165\u767B\u5F55\u90AE\u7BB1\u4EE5\u786E\u8BA4", htmlFor: "profile-delete-email", children: _jsx(Input, { id: "profile-delete-email", placeholder: user.email, value: confirmEmail, onChange: (e) => setConfirmEmail(e.target.value) }) }) }) }), _jsx(ImpactConfirmation, { open: confirmOpen, onOpenChange: setConfirmOpen, onConfirm: handleDelete, title: "\u786E\u8BA4\u5220\u9664\u8D26\u53F7\uFF1F", confirmText: "\u6C38\u4E45\u5220\u9664", danger: true, impacts: [
92
+ '账号进入 deleted 状态,邮箱被释放(可用同邮箱重新注册)',
93
+ '所有会话立即失效并吊销全部个人访问令牌(PAT)',
94
+ '昵称、头像等资料被清空,操作不可恢复',
95
+ ] })] }));
96
+ }
@@ -0,0 +1 @@
1
+ export declare function SecuritySettingsPage(): import("react").JSX.Element;
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { z } from 'zod';
4
+ import { PageHeader, PageError } from '../../components/Page';
5
+ import { SettingsCard } from '../../components/SettingsCard';
6
+ import { FormField } from '../../components/FormField';
7
+ import { Button } from '../../components/ui/Button';
8
+ import { Input } from '../../components/ui/Input';
9
+ import { Badge } from '../../components/ui/Badge';
10
+ import { DataTable } from '../../components/DataTable';
11
+ import { ImpactConfirmation } from '../../components/SecretModal';
12
+ import { useConfirm } from '../../components/ConfirmDialog';
13
+ import { toast } from '../../components/toast';
14
+ import { useApi } from '../../hooks/useApi';
15
+ import { useAuth } from '../../hooks/useAuth';
16
+ import { useZodForm } from '../../hooks/useZodForm';
17
+ import { useSessions, useRevokeSession, useRevokeAllSessions } from '../../hooks/useSessions';
18
+ import { passwordSchema } from '../auth/RegisterPage';
19
+ import { apiErrorMessage } from '../../lib/api-error';
20
+ import { formatDateTime, shortID } from '../../lib/utils';
21
+ const changePwSchema = z
22
+ .object({
23
+ old_password: z.string().min(1, '请输入当前密码'),
24
+ new_password: passwordSchema,
25
+ })
26
+ // B4(ui-audit-10pages §4.2,S3):new==old 客户端拦截——相同值提交后端会
27
+ // 判定通过并"成功"改密,顺带静默吊销全部会话(服务端无 new==old 校验,
28
+ // 半边 ➡️U1 identity 归属)。跨字段校验须挂对象级 refine(字段级拿不到 old);
29
+ // zod 语义:字段规则全过才跑 refine,old 为空时不会叠加误导性报错。
30
+ .superRefine((v, ctx) => {
31
+ if (v.old_password !== '' && v.new_password === v.old_password) {
32
+ ctx.addIssue({
33
+ code: 'custom',
34
+ path: ['new_password'],
35
+ message: '新密码不能与当前密码相同',
36
+ });
37
+ }
38
+ });
39
+ function truncate(s, max = 32) {
40
+ return s.length > max ? s.slice(0, max) + '…' : s;
41
+ }
42
+ export function SecuritySettingsPage() {
43
+ const api = useApi();
44
+ const { refresh, logout } = useAuth();
45
+ const confirm = useConfirm();
46
+ const { sessions, loading, error, refetch } = useSessions();
47
+ const { revoke } = useRevokeSession();
48
+ const { revokeAll } = useRevokeAllSessions();
49
+ const [saving, setSaving] = useState(false);
50
+ const [revokingAll, setRevokingAll] = useState(false);
51
+ const [confirmAllOpen, setConfirmAllOpen] = useState(false);
52
+ const form = useZodForm({
53
+ schema: changePwSchema,
54
+ defaultValues: { old_password: '', new_password: '' },
55
+ });
56
+ const handleChangePw = form.handleSubmit(async (values) => {
57
+ setSaving(true);
58
+ try {
59
+ await api.post('/auth/change-password', {
60
+ old_password: values.old_password,
61
+ new_password: values.new_password,
62
+ });
63
+ toast.success('密码已修改,其他设备可能需要重新登录');
64
+ form.reset();
65
+ await refresh();
66
+ }
67
+ catch (e) {
68
+ toast.error(apiErrorMessage(e, '修改失败'));
69
+ }
70
+ finally {
71
+ setSaving(false);
72
+ }
73
+ });
74
+ const handleRevoke = async (s) => {
75
+ const ok = await confirm({
76
+ title: '吊销此会话?',
77
+ description: s.current ? '这是当前设备,吊销后需要重新登录。' : truncate(s.user_agent),
78
+ confirmText: '吊销',
79
+ danger: true,
80
+ });
81
+ if (!ok)
82
+ return;
83
+ try {
84
+ await revoke(s.id);
85
+ if (s.current) {
86
+ toast.success('已吊销当前会话,即将返回登录页');
87
+ await logout();
88
+ window.location.href = '/login';
89
+ return;
90
+ }
91
+ toast.success('会话已吊销');
92
+ }
93
+ catch (e) {
94
+ toast.error(apiErrorMessage(e, '吊销失败'));
95
+ }
96
+ };
97
+ const handleRevokeAll = async () => {
98
+ setRevokingAll(true);
99
+ try {
100
+ await revokeAll();
101
+ toast.success('已吊销全部会话');
102
+ window.location.href = '/login';
103
+ }
104
+ catch (e) {
105
+ toast.error(apiErrorMessage(e, '吊销失败'));
106
+ setRevokingAll(false);
107
+ }
108
+ };
109
+ const columns = [
110
+ {
111
+ key: 'user_agent', header: '设备',
112
+ render: (s) => (_jsx("span", { title: s.user_agent, className: "text-muted-foreground inline-block max-w-64 truncate", children: truncate(s.user_agent) })),
113
+ },
114
+ {
115
+ key: 'ip_hash', header: 'IP(哈希)',
116
+ render: (s) => _jsx("span", { title: s.ip_hash, className: "font-mono text-xs", children: shortID(s.ip_hash, 10) }),
117
+ },
118
+ { key: 'last_seen_at', header: '最近活跃', render: (s) => formatDateTime(s.last_seen_at) },
119
+ { key: 'expires_at', header: '过期时间', render: (s) => formatDateTime(s.expires_at) },
120
+ {
121
+ key: 'current', header: '当前',
122
+ render: (s) => s.current === undefined ? (_jsx("span", { className: "text-muted-foreground", children: "\u2014" })) : s.current ? (_jsx(Badge, { variant: "secondary", className: "bg-emerald-50 text-emerald-700 border-emerald-200", children: "\u5F53\u524D\u8BBE\u5907" })) : (_jsx("span", { className: "text-muted-foreground", children: "\u2014" })),
123
+ },
124
+ {
125
+ key: 'actions', header: '', className: 'text-right',
126
+ render: (s) => (_jsx(Button, { variant: "ghost", size: "sm", className: "text-destructive", onClick: () => handleRevoke(s), children: "\u540A\u9500" })),
127
+ },
128
+ ];
129
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u8D26\u6237\u5B89\u5168", description: "\u4FEE\u6539\u5BC6\u7801\u5E76\u7BA1\u7406\u5DF2\u767B\u5F55\u8BBE\u5907" }), _jsx(SettingsCard, { title: "\u4FEE\u6539\u5BC6\u7801", description: "\u4FEE\u6539\u540E\u5176\u4ED6\u4F1A\u8BDD\u5C06\u5931\u6548\uFF0C\u9700\u8981\u91CD\u65B0\u767B\u5F55", cta: _jsx(Button, { type: "submit", form: "security-pw-form", disabled: saving, children: saving ? '提交中…' : '修改密码' }), children: _jsxs("form", { id: "security-pw-form", onSubmit: handleChangePw, className: "max-w-xl space-y-4", children: [_jsx(FormField, { label: "\u5F53\u524D\u5BC6\u7801", error: form.formState.errors.old_password?.message, htmlFor: "sec-old", children: _jsx(Input, { id: "sec-old", type: "password", autoComplete: "current-password", ...form.register('old_password') }) }), _jsx(FormField, { label: "\u65B0\u5BC6\u7801", error: form.formState.errors.new_password?.message, htmlFor: "sec-new", children: _jsx(Input, { id: "sec-new", type: "password", placeholder: "\u81F3\u5C11 10 \u4F4D\uFF0C\u542B\u4E09\u7C7B\u5B57\u7B26", autoComplete: "new-password", ...form.register('new_password') }) })] }) }), _jsx(SettingsCard, { title: "\u6D3B\u8DC3\u4F1A\u8BDD", description: "\u6700\u8FD1\u767B\u5F55\u8FC7\u4F60\u8D26\u53F7\u7684\u8BBE\u5907\uFF1B\u53EF\u7591\u4F1A\u8BDD\u8BF7\u7ACB\u5373\u540A\u9500", cta: _jsx(Button, { variant: "destructive", size: "sm", disabled: revokingAll, onClick: () => setConfirmAllOpen(true), children: "\u5168\u90E8\u540A\u9500" }), children: error ? (_jsx(PageError, { message: apiErrorMessage(error, '会话列表加载失败'), onRetry: () => refetch() })) : (_jsx(DataTable, { columns: columns, rows: sessions, rowKey: (s) => s.id, loading: loading, empty: _jsx("div", { className: "py-8 text-center text-sm text-muted-foreground", children: "\u6682\u65E0\u6D3B\u8DC3\u4F1A\u8BDD" }) })) }), _jsx(ImpactConfirmation, { open: confirmAllOpen, onOpenChange: setConfirmAllOpen, onConfirm: handleRevokeAll, title: "\u540A\u9500\u5168\u90E8\u4F1A\u8BDD\uFF1F", confirmText: "\u5168\u90E8\u540A\u9500", danger: true, impacts: [
130
+ '所有设备(含当前设备)立即退出登录',
131
+ '会话 cookie 被清除,页面将跳转登录页',
132
+ '个人访问令牌(PAT)不受影响,仍需单独吊销',
133
+ ] })] }));
134
+ }
@@ -0,0 +1 @@
1
+ export declare function TokensPage(): import("react").JSX.Element;
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { PageHeader, PageEmpty } from '../../components/Page';
4
+ import { FormField } from '../../components/FormField';
5
+ import { Button } from '../../components/ui/Button';
6
+ import { Input } from '../../components/ui/Input';
7
+ import { Badge } from '../../components/ui/Badge';
8
+ import { DataTable } from '../../components/DataTable';
9
+ import { SecretModal } from '../../components/SecretModal';
10
+ import { useConfirm } from '../../components/ConfirmDialog';
11
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '../../components/ui/dialog';
12
+ import { toast } from '../../components/toast';
13
+ import { useTokens, useCreateToken, useRevokeToken } from '../../hooks/useTokens';
14
+ import { apiErrorMessage } from '../../lib/api-error';
15
+ import { formatDateTime } from '../../lib/utils';
16
+ const NAME_MAX_BYTES = 64;
17
+ const utf8Bytes = (s) => new TextEncoder().encode(s).length;
18
+ export function TokensPage() {
19
+ const { tokens, loading, refetch } = useTokens();
20
+ const { create } = useCreateToken();
21
+ const { revoke } = useRevokeToken();
22
+ const confirm = useConfirm();
23
+ const [createOpen, setCreateOpen] = useState(false);
24
+ const [name, setName] = useState('');
25
+ const [nameError, setNameError] = useState(null);
26
+ const [ttlHours, setTtlHours] = useState('');
27
+ const [ttlError, setTtlError] = useState(null);
28
+ const [creating, setCreating] = useState(false);
29
+ const [secret, setSecret] = useState(null);
30
+ function parseTtl(raw) {
31
+ const s = raw.trim();
32
+ if (s === '')
33
+ return { ok: true };
34
+ const n = Number(s);
35
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
36
+ return { ok: false, error: '有效期必须是正整数(小时);不需要过期时间请留空' };
37
+ }
38
+ return { ok: true, hours: n };
39
+ }
40
+ const handleCreate = async () => {
41
+ const trimmed = name.trim();
42
+ if (!trimmed)
43
+ return;
44
+ if (utf8Bytes(trimmed) > NAME_MAX_BYTES) {
45
+ setNameError(`名称不能超过 64 字节(约 21 个汉字),当前 ${utf8Bytes(trimmed)} 字节`);
46
+ return;
47
+ }
48
+ const ttl = parseTtl(ttlHours);
49
+ if (!ttl.ok) {
50
+ setTtlError(ttl.error);
51
+ return;
52
+ }
53
+ setNameError(null);
54
+ setTtlError(null);
55
+ setCreating(true);
56
+ try {
57
+ const res = await create(trimmed, ttl.hours);
58
+ setCreateOpen(false);
59
+ setName('');
60
+ setTtlHours('');
61
+ setSecret(res.token);
62
+ toast.success('令牌已创建');
63
+ }
64
+ catch (e) {
65
+ toast.error(apiErrorMessage(e, '创建失败'));
66
+ }
67
+ finally {
68
+ setCreating(false);
69
+ }
70
+ };
71
+ const handleRevoke = async (t) => {
72
+ const ok = await confirm({
73
+ title: '吊销此令牌?',
74
+ description: `使用「${t.name}」(前缀 ${t.prefix}…)的集成将立即失去访问权限。`,
75
+ confirmText: '吊销',
76
+ danger: true,
77
+ });
78
+ if (!ok)
79
+ return;
80
+ try {
81
+ await revoke(t.id);
82
+ toast.success('令牌已吊销');
83
+ }
84
+ catch (e) {
85
+ toast.error(apiErrorMessage(e, '吊销失败'));
86
+ }
87
+ };
88
+ const columns = [
89
+ {
90
+ key: 'name', header: '名称',
91
+ render: (t) => (_jsx("span", { className: "font-medium block max-w-[16rem] truncate", title: t.name, children: t.name })),
92
+ },
93
+ { key: 'prefix', header: '前缀', render: (t) => _jsxs("code", { className: "font-mono text-xs", children: [t.prefix, "\u2026"] }) },
94
+ {
95
+ key: 'last_used_at', header: '最近使用',
96
+ render: (t) => (t.last_used_at ? formatDateTime(t.last_used_at) : _jsx("span", { className: "text-muted-foreground", children: "\u4ECE\u672A\u4F7F\u7528" })),
97
+ },
98
+ {
99
+ key: 'expires_at', header: '过期时间',
100
+ render: (t) => t.expires_at ? (formatDateTime(t.expires_at)) : (_jsx(Badge, { variant: "outline", children: "\u6C38\u4E0D\u8FC7\u671F" })),
101
+ },
102
+ { key: 'created_at', header: '创建时间', render: (t) => formatDateTime(t.created_at) },
103
+ {
104
+ key: 'actions', header: '', className: 'text-right',
105
+ render: (t) => (_jsx(Button, { variant: "ghost", size: "sm", className: "text-destructive", "aria-label": `吊销令牌 ${t.name}`, onClick: () => handleRevoke(t), children: "\u540A\u9500" })),
106
+ },
107
+ ];
108
+ return (_jsxs("div", { children: [_jsx(PageHeader, { title: "\u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C", description: "\u7528\u4E8E\u811A\u672C\u4E0E API \u96C6\u6210\uFF1B\u660E\u6587\u53EA\u5728\u521B\u5EFA\u65F6\u663E\u793A\u4E00\u6B21", actions: _jsx(Button, { onClick: () => setCreateOpen(true), children: "\u65B0\u5EFA\u4EE4\u724C" }) }), _jsx(DataTable, { columns: columns, rows: tokens, rowKey: (t) => t.id, loading: loading, empty: _jsx(PageEmpty, { label: "\u8FD8\u6CA1\u6709\u4EE4\u724C", hint: "\u521B\u5EFA\u4E00\u4E2A\u4EE4\u724C\uFF0C\u8BA9\u811A\u672C\u4EE5\u4F60\u7684\u8EAB\u4EFD\u8C03\u7528 API" }) }), _jsx(Dialog, { open: createOpen, onOpenChange: setCreateOpen, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "\u65B0\u5EFA\u4EE4\u724C" }), _jsx(DialogDescription, { children: "\u521B\u5EFA\u540E\u660E\u6587\u53EA\u663E\u793A\u4E00\u6B21\uFF0C\u8BF7\u7ACB\u5373\u4FDD\u5B58\u5230\u5B89\u5168\u7684\u5730\u65B9\u3002" })] }), _jsxs("div", { className: "space-y-4", children: [_jsx(FormField, { label: "\u540D\u79F0", htmlFor: "pat-name", error: nameError ?? undefined, children: _jsx(Input, { id: "pat-name", value: name, onChange: (e) => {
109
+ setName(e.target.value);
110
+ if (nameError)
111
+ setNameError(null);
112
+ }, placeholder: "\u5982\uFF1Aci-deploy", maxLength: 64, autoFocus: true, "aria-invalid": nameError ? true : undefined }) }), _jsx(FormField, { label: "\u6709\u6548\u671F\uFF08\u5C0F\u65F6\uFF0C\u53EF\u9009\uFF09", htmlFor: "pat-ttl", error: ttlError ?? undefined, children: _jsx(Input, { id: "pat-ttl", type: "text", inputMode: "numeric", value: ttlHours, onChange: (e) => {
113
+ setTtlHours(e.target.value);
114
+ if (ttlError)
115
+ setTtlError(null);
116
+ }, placeholder: "\u7559\u7A7A = \u6C38\u4E0D\u8FC7\u671F\uFF08\u4E0D\u5EFA\u8BAE\uFF09", "aria-invalid": ttlError ? true : undefined }) })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setCreateOpen(false), children: "\u53D6\u6D88" }), _jsx(Button, { onClick: handleCreate, disabled: creating || !name.trim(), children: creating ? '创建中…' : '创建' })] })] }) }), _jsx(SecretModal, { open: secret !== null, onOpenChange: (o) => !o && setSecret(null), secretName: "\u4E2A\u4EBA\u8BBF\u95EE\u4EE4\u724C", secretValue: secret ?? '' })] }));
117
+ }
@@ -0,0 +1 @@
1
+ export declare function AdminSettingsPage(): import("react").JSX.Element;