From 93ab6a7b9e81803dd9eeae2fd9e17c54affaaf76 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 1 Oct 2026 13:28:42 +0800 Subject: [PATCH] feat(web): migrate account and user management to React Add React account and user administration flows, including TOTP and SSO handling. Route /next SSO callbacks to React while preserving the existing Vue and native app behavior, and cover the routing policy with API tests. --- src/Jiaowu.Api/Controllers/SsoController.cs | 65 ++- tests/Jiaowu.Api.Tests/SsoControllerTests.cs | 17 + web-react/MIGRATION_MATRIX.md | 4 +- web-react/README.md | 5 +- web-react/src/api/files.ts | 38 ++ web-react/src/app/App.tsx | 21 +- web-react/src/components/PageHeader.tsx | 22 + web-react/src/design/global.css | 9 + web-react/src/layout/AppShell.tsx | 12 +- web-react/src/pages/AccountPage.tsx | 290 +++++++++++++ .../src/pages/MigrationPlaceholderPage.tsx | 28 -- web-react/src/pages/SsoBindPage.tsx | 54 +++ web-react/src/pages/SsoCallbackPage.tsx | 45 ++ web-react/src/pages/UsersPage.tsx | 389 ++++++++++++++++++ web-react/src/pages/account.css | 46 +++ web-react/src/pages/users.css | 53 +++ web-react/src/stores/auth.ts | 20 + 17 files changed, 1058 insertions(+), 60 deletions(-) create mode 100644 web-react/src/api/files.ts create mode 100644 web-react/src/components/PageHeader.tsx create mode 100644 web-react/src/pages/AccountPage.tsx delete mode 100644 web-react/src/pages/MigrationPlaceholderPage.tsx create mode 100644 web-react/src/pages/SsoBindPage.tsx create mode 100644 web-react/src/pages/SsoCallbackPage.tsx create mode 100644 web-react/src/pages/UsersPage.tsx create mode 100644 web-react/src/pages/account.css create mode 100644 web-react/src/pages/users.css diff --git a/src/Jiaowu.Api/Controllers/SsoController.cs b/src/Jiaowu.Api/Controllers/SsoController.cs index df2b646..fb051a3 100644 --- a/src/Jiaowu.Api/Controllers/SsoController.cs +++ b/src/Jiaowu.Api/Controllers/SsoController.cs @@ -56,6 +56,7 @@ public sealed class SsoController( return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); var safeReturnUrl = NormalizeReturnUrl(returnUrl); + var accountPage = FrontendPagePath(safeReturnUrl, "account", nativeApp); if (string.IsNullOrWhiteSpace(bindingIntent) && (string.IsNullOrWhiteSpace(captchaId) || string.IsNullOrWhiteSpace(captchaCode) || @@ -77,7 +78,7 @@ public sealed class SsoController( BindingIntentCacheKey(bindingIntent), cancellationToken); if (targetUserId is null) - return RedirectToFrontendError("binding_intent_expired", "/account"); + return RedirectToFrontendError("binding_intent_expired", accountPage); properties.Items[BindingIntentProperty] = bindingIntent; } var completeUrl = Url.Action( @@ -97,7 +98,9 @@ public sealed class SsoController( EffectiveCallbackUrl()); return RedirectToFrontendError( "configuration_error", - string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account"); + string.IsNullOrWhiteSpace(bindingIntent) + ? FrontendPagePath(safeReturnUrl, "login", nativeApp) + : accountPage); } } @@ -111,16 +114,23 @@ public sealed class SsoController( if (!_options.Enabled) return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound); + var safeReturnUrl = NormalizeReturnUrl(returnUrl); var authentication = await HttpContext.AuthenticateAsync( SsoAuthSchemes.ExternalCookie); + var nativeApp = authentication.Properties is { } externalProperties && + externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) && + bool.TryParse(nativeAppValue, out var isNativeApp) && + isNativeApp; + var loginPage = FrontendPagePath(safeReturnUrl, "login", nativeApp); + var accountPage = FrontendPagePath(safeReturnUrl, "account", nativeApp); if (!authentication.Succeeded || authentication.Principal is null) - return RedirectToFrontendError("authentication_failed"); + return RedirectToFrontendError("authentication_failed", loginPage); var principal = authentication.Principal; var subject = principal.FindFirstValue("sub") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(subject)) - return RedirectToFrontendError("missing_subject"); + return RedirectToFrontendError("missing_subject", loginPage); ApplicationUser? user = null; var bindingIntent = @@ -130,10 +140,6 @@ public sealed class SsoController( out var storedBindingIntent) ? storedBindingIntent : null; - var nativeApp = authentication.Properties is { } externalProperties && - externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) && - bool.TryParse(nativeAppValue, out var isNativeApp) && - isNativeApp; var nativeState = authentication.Properties is { } nativeProperties && nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState) ? storedNativeState @@ -147,13 +153,13 @@ public sealed class SsoController( ? null : await userManager.FindByIdAsync(targetUserId); if (user is null) - return RedirectToFrontendError("binding_intent_expired", "/account"); + return RedirectToFrontendError("binding_intent_expired", accountPage); if (!user.IsEnabled || await userManager.IsLockedOutAsync(user)) - return RedirectToFrontendError("account_disabled", "/account"); + return RedirectToFrontendError("account_disabled", accountPage); var linkError = await LinkSsoIdentityAsync(user, subject); if (linkError is not null) - return RedirectToFrontendError(linkError, "/account"); + return RedirectToFrontendError(linkError, accountPage); await cache.RemoveAsync( BindingIntentCacheKey(bindingIntent), cancellationToken); @@ -172,7 +178,7 @@ public sealed class SsoController( { var linkError = await LinkSsoIdentityAsync(user, subject); if (linkError is not null) - return RedirectToFrontendError(linkError); + return RedirectToFrontendError(linkError, loginPage); } } } @@ -195,18 +201,20 @@ public sealed class SsoController( cancellationToken); await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie); - var bindingPage = BuildFrontendUrl("/sso/bind", nativeApp) + + var bindingPage = BuildFrontendUrl( + FrontendPagePath(safeReturnUrl, "sso/bind", nativeApp), + nativeApp) + $"?code={Uri.EscapeDataString(bindingCode)}" + - $"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}"; + $"&redirect={Uri.EscapeDataString(safeReturnUrl)}"; return Redirect(bindingPage); } if (!user.IsEnabled || await userManager.IsLockedOutAsync(user)) - return RedirectToFrontendError("account_disabled"); + return RedirectToFrontendError("account_disabled", loginPage); user.LastLoginAt = DateTime.UtcNow; var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded) - return RedirectToFrontendError("account_update_failed"); + return RedirectToFrontendError("account_update_failed", loginPage); var exchangeCode = WebEncoders.Base64UrlEncode( RandomNumberGenerator.GetBytes(32)); @@ -220,9 +228,11 @@ public sealed class SsoController( cancellationToken); await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie); - var callback = BuildFrontendUrl("/sso/callback", nativeApp) + + var callback = BuildFrontendUrl( + FrontendPagePath(safeReturnUrl, "sso/callback", nativeApp), + nativeApp) + $"?code={Uri.EscapeDataString(exchangeCode)}" + - $"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}"; + $"&redirect={Uri.EscapeDataString(safeReturnUrl)}"; return Redirect(callback); } @@ -386,6 +396,7 @@ public sealed class SsoController( [HttpPost("prepare-binding")] public async Task> PrepareBinding( CancellationToken cancellationToken, + [FromQuery] string? returnUrl = null, [FromQuery] bool nativeApp = false, [FromQuery] string? nativeState = null) { @@ -411,11 +422,14 @@ public sealed class SsoController( AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, cancellationToken); + var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) + ? "/account" + : NormalizeReturnUrl(returnUrl); var loginUrl = Url.Action( nameof(Login), values: new { - returnUrl = "/account", + returnUrl = safeReturnUrl, bindingIntent = intentCode, nativeApp, nativeState, @@ -464,6 +478,19 @@ public sealed class SsoController( ? returnUrl : "/dashboard"; + internal static string FrontendPagePath( + string? returnUrl, + string page, + bool nativeApp = false) + { + var normalizedPage = "/" + page.Trim('/'); + if (nativeApp) return normalizedPage; + var normalizedReturnUrl = NormalizeReturnUrl(returnUrl); + return normalizedReturnUrl.StartsWith("/next/", StringComparison.OrdinalIgnoreCase) + ? "/next" + normalizedPage + : normalizedPage; + } + private string BuildFrontendUrl(string path, bool nativeApp = false) { if (nativeApp) diff --git a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs index d906214..104d2b4 100644 --- a/tests/Jiaowu.Api.Tests/SsoControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/SsoControllerTests.cs @@ -119,6 +119,23 @@ public sealed class SsoControllerTests Assert.Equal(expected, SsoController.NormalizeReturnUrl(value)); } + [Theory] + [InlineData("/dashboard", "sso/callback", false, "/sso/callback")] + [InlineData("/next/dashboard", "sso/callback", false, "/next/sso/callback")] + [InlineData("/next/account", "account", false, "/next/account")] + [InlineData("/next/dashboard", "sso/callback", true, "/sso/callback")] + [InlineData("https://evil.example/path", "login", false, "/login")] + public void FrontendPagePath_SelectsReactOnlyForNextWebRoutes( + string? returnUrl, + string page, + bool nativeApp, + string expected) + { + Assert.Equal( + expected, + SsoController.FrontendPagePath(returnUrl, page, nativeApp)); + } + private sealed class BindingFixture : IAsyncDisposable { private readonly SqliteConnection _connection; diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index ac13963..5dfa2e6 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -6,7 +6,7 @@ | --- | --- | --- | --- | --- | | `/login` | 登录 | 公开 | 验证码、密码、TOTP、SSO 设置、会话落盘 | building | | `/dashboard` | 教务总览 | 已登录用户 | `GET /api/dashboard`、角色视图、待办和学期 | building | -| `/account` | 个人账户 | 已登录用户 | TOTP、Passkey、恢复码、SSO 绑定 | inventory | -| `/users` | 用户与权限 | SuperAdmin | 列表、筛选、分页、创建、重置密码、角色范围 | inventory | +| `/account` | 个人账户 | 已登录用户 | TOTP、恢复码、SSO 绑定;Passkey 浏览器仪式尚未开放 | verification | +| `/users` | 用户与权限 | SuperAdmin | 列表、筛选、分页、创建、启停、重置密码、角色范围、Excel | verification | 后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。 diff --git a/web-react/README.md b/web-react/README.md index 19fdd7c..450f714 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -27,7 +27,8 @@ npm.cmd run build - 账号密码、验证码和 TOTP 登录; - 权限感知的应用外壳; - 读取真实 `/api/dashboard` 数据的首个页面; -- `/account` 和 `/users` 迁移占位路由,可安全返回现有 Vue 页面。 +- 完整迁移 `/account` 的身份信息、TOTP 和统一身份绑定流程; +- 完整迁移 `/users` 的分页筛选、Excel、账号、角色、状态和密码管理流程。 ## 迁移纪律 @@ -42,7 +43,7 @@ npm.cmd run build | 批次 | 内容 | 状态 | | --- | --- | --- | | 0 | 工程、主题、会话、登录、应用外壳 | 进行中 | -| 1 | Dashboard、Users、Account、移动端代表页 | Dashboard 首版已接入 | +| 1 | Dashboard、Users、Account、移动端代表页 | 前三项进入联调验收 | | 2 | 标准 CRUD 与基础数据模块 | 未开始 | | 3 | 学生、教师与审批工作台 | 未开始 | | 4 | 选课、排课、成绩、实验等复杂模块 | 未开始 | diff --git a/web-react/src/api/files.ts b/web-react/src/api/files.ts new file mode 100644 index 0000000..5e80e19 --- /dev/null +++ b/web-react/src/api/files.ts @@ -0,0 +1,38 @@ +import type { AxiosRequestConfig } from 'axios' +import http from './http' + +function responseFileName(header: string | undefined, fallback: string) { + if (!header) return fallback + const encoded = header.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) return decodeURIComponent(encoded) + const plain = header.match(/filename="?([^";]+)"?/i)?.[1] + return plain || fallback +} + +export async function downloadApiFile( + path: string, + fallbackName: string, + config?: AxiosRequestConfig, +) { + const response = await http.get(path, { ...config, responseType: 'blob' }) + const url = URL.createObjectURL(response.data) + try { + const anchor = document.createElement('a') + anchor.href = url + anchor.download = responseFileName(response.headers['content-disposition'], fallbackName) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + } finally { + URL.revokeObjectURL(url) + } +} + +export async function importExcel(path: string, file: File) { + const form = new FormData() + form.append('file', file) + return http.post(path, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 60000, + }) +} diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 3331218..7e5aa3f 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useEffect } from 'react' -import { ConfigProvider, Skeleton } from 'antd' +import { App as AntdApp, ConfigProvider, Skeleton } from 'antd' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import zhCN from 'antd/locale/zh_CN' @@ -7,7 +7,10 @@ import { useAuthStore } from '../stores/auth' const LoginPage = lazy(() => import('../pages/LoginPage')) const DashboardPage = lazy(() => import('../pages/DashboardPage')) -const MigrationPlaceholderPage = lazy(() => import('../pages/MigrationPlaceholderPage')) +const AccountPage = lazy(() => import('../pages/AccountPage')) +const UsersPage = lazy(() => import('../pages/UsersPage')) +const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage')) +const SsoBindPage = lazy(() => import('../pages/SsoBindPage')) const AppShell = lazy(() => import('../layout/AppShell')) const queryClient = new QueryClient({ @@ -43,10 +46,12 @@ function AppRoutes() { } /> + } /> + } /> }> } /> - } /> - } /> + } /> + } /> } /> } /> @@ -79,9 +84,11 @@ export default function App() { }, }} > - - - + + + + + ) } diff --git a/web-react/src/components/PageHeader.tsx b/web-react/src/components/PageHeader.tsx new file mode 100644 index 0000000..6849e8f --- /dev/null +++ b/web-react/src/components/PageHeader.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +interface PageHeaderProps { + kicker: string + title: string + description: string + actions?: ReactNode + aside?: ReactNode +} + +export default function PageHeader({ kicker, title, description, actions, aside }: PageHeaderProps) { + return ( +
+
+ {kicker} +

{title}

+

{description}

+
+ {aside ?? actions} +
+ ) +} diff --git a/web-react/src/design/global.css b/web-react/src/design/global.css index a7b8f76..8b4f3a2 100644 --- a/web-react/src/design/global.css +++ b/web-react/src/design/global.css @@ -68,6 +68,15 @@ a { color: inherit; } .login-links a:hover { color: var(--teal); } .form-error { margin: -4px 0 0; padding: 10px 12px; border-left: 3px solid var(--danger); color: #8c2f27; background: var(--danger-soft); font-size: 12px; line-height: 1.55; } +.auth-transition-page { min-height: 100vh; padding: 24px; display: grid; place-items: center; background: radial-gradient(circle at 50% 0, #e7eef6, transparent 38%), var(--canvas); } +.auth-transition-page > section { width: min(100%, 500px); padding: 34px; border: 1px solid var(--line); background: var(--paper); box-shadow: var(--shadow-panel); } +.auth-transition-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 30px; color: var(--brand-deep); } +.auth-transition-brand strong { font: 600 18px var(--font-display); letter-spacing: .1em; } +.auth-transition-status { min-height: 240px; display: grid; place-items: center; align-content: center; text-align: center; } +.auth-transition-status h1 { margin: 22px 0 8px; color: var(--brand-deep); font: 600 23px var(--font-display); } +.auth-transition-status p { margin: 0; color: var(--ink-muted); font-size: 12px; } +.sso-bind-panel .ant-alert { margin-bottom: 18px; } + .captcha { display: grid; gap: 8px; } .captcha__heading { display: flex; justify-content: space-between; align-items: center; color: #263750; font-size: 13px; font-weight: 650; } .captcha__refresh { padding: 0; border: 0; color: #47729d; background: transparent; cursor: pointer; font-size: 12px; } diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 7a3045f..d23b68c 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { AppstoreOutlined, BellOutlined, + ExportOutlined, IdcardOutlined, MenuFoldOutlined, MenuUnfoldOutlined, @@ -31,6 +32,7 @@ export default function AppShell() { ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), + { key: 'legacy', icon: , label: '返回现有系统' }, ], [user?.roles]) const pageTitle = items.find((item) => location.pathname.startsWith(item.key))?.label ?? '迁移预览' @@ -58,9 +60,15 @@ export default function AppShell() { className="shell-menu" theme="dark" mode="inline" - selectedKeys={[items.find((item) => location.pathname.startsWith(item.key))?.key ?? '/dashboard']} + selectedKeys={[items.find((item) => item.key.startsWith('/') && location.pathname.startsWith(item.key))?.key ?? '/dashboard']} items={items} - onClick={({ key }) => navigate(key)} + onClick={({ key }) => { + if (key === 'legacy') { + window.location.assign(import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255') + return + } + navigate(key) + }} /> diff --git a/web-react/src/pages/AccountPage.tsx b/web-react/src/pages/AccountPage.tsx new file mode 100644 index 0000000..9b81820 --- /dev/null +++ b/web-react/src/pages/AccountPage.tsx @@ -0,0 +1,290 @@ +import { useEffect, useState } from 'react' +import { + App, + Alert, + Avatar, + Button, + Descriptions, + Form, + Input, + Modal, + Skeleton, + Tag, +} from 'antd' +import { + ApiOutlined, + KeyOutlined, + LinkOutlined, + LockOutlined, + SafetyCertificateOutlined, +} from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useLocation, useNavigate } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import { isNativeApp } from '../auth/session' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './account.css' + +interface AccountSecurityData { + sso: { + enabled: boolean + providerDisplayName: string + isBound: boolean + callbackUrl: string + } + totpEnabled: boolean +} + +interface PasswordForm { + currentPassword: string +} + +interface TotpSetupResponse { + secret: string + otpauthUri: string +} + +const scopeNames: Record = { + Self: '本人数据', + Class: '所带班级', + College: '所属学院', + All: '全校数据', +} + +const ssoErrors: Record = { + binding_intent_expired: '绑定请求已失效,请重新发起绑定。', + identity_already_bound: '该统一身份账号已经绑定其他平台账号。', + account_already_bound: '当前平台账号已经绑定其他统一身份账号。', + account_disabled: '当前平台账号已停用或锁定。', + account_link_failed: '统一身份账户绑定失败,请重新尝试。', + configuration_error: '统一身份认证回调地址配置不正确,请联系管理员。', +} + +export default function AccountPage() { + const { message } = App.useApp() + const location = useLocation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const user = useAuthStore((state) => state.user) + const [totpOpen, setTotpOpen] = useState(false) + const [totpStage, setTotpStage] = useState<'password' | 'verify'>('password') + const [totpSetup, setTotpSetup] = useState(null) + const [recoveryCodes, setRecoveryCodes] = useState([]) + const [recoveryOpen, setRecoveryOpen] = useState(false) + const [disableOpen, setDisableOpen] = useState(false) + const [unbindOpen, setUnbindOpen] = useState(false) + const [totpPasswordForm] = Form.useForm() + const [totpCodeForm] = Form.useForm<{ code: string }>() + const [disableForm] = Form.useForm() + const [unbindForm] = Form.useForm<{ password: string }>() + + const securityQuery = useQuery({ + queryKey: ['account-security'], + queryFn: async (): Promise => { + const [ssoResult, totpResult] = await Promise.all([ + http.get('/auth/sso/account'), + http.get('/auth/security/totp'), + ]) + return { + sso: { + enabled: Boolean(ssoResult.data.enabled), + providerDisplayName: String(ssoResult.data.providerDisplayName || '学校统一身份认证'), + isBound: Boolean(ssoResult.data.isBound), + callbackUrl: String(ssoResult.data.callbackUrl || ''), + }, + totpEnabled: Boolean(totpResult.data.enabled), + } + }, + }) + + useEffect(() => { + const query = new URLSearchParams(location.search) + const ssoError = query.get('ssoError') ?? '' + if (!ssoError) return + message.error(ssoErrors[ssoError] ?? '统一身份账户绑定失败,请重新尝试。') + navigate('/account', { replace: true }) + }, [location.search, message, navigate]) + + async function refreshSecurity() { + await queryClient.invalidateQueries({ queryKey: ['account-security'] }) + } + + const setupMutation = useMutation({ + mutationFn: (values: PasswordForm) => http.post('/auth/security/totp/setup', values), + onSuccess: ({ data }) => { + setTotpSetup(data) + setTotpStage('verify') + }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + + const enableMutation = useMutation({ + mutationFn: (values: { code: string }) => http.post<{ recoveryCodes: string[] }>('/auth/security/totp/enable', values), + onSuccess: async ({ data }) => { + setRecoveryCodes(data.recoveryCodes) + setTotpOpen(false) + setRecoveryOpen(true) + message.success('动态验证码已启用,请立即保存恢复码。') + await refreshSecurity() + }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + + const disableMutation = useMutation({ + mutationFn: (values: PasswordForm) => http.post('/auth/security/totp/disable', values), + onSuccess: async () => { + setDisableOpen(false) + disableForm.resetFields() + message.success('动态验证码已关闭。') + await refreshSecurity() + }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + + const unbindMutation = useMutation({ + mutationFn: (values: { password: string }) => http.post('/auth/sso/unbind', values), + onSuccess: async () => { + setUnbindOpen(false) + unbindForm.resetFields() + message.success('统一身份账户已解绑。') + await refreshSecurity() + }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + + function beginTotpSetup() { + setTotpStage('password') + setTotpSetup(null) + totpPasswordForm.resetFields() + totpCodeForm.resetFields() + setTotpOpen(true) + } + + async function startBinding() { + if (isNativeApp()) { + message.info('React 原生端统一身份绑定将在移动端迁移批次启用,请暂时使用现有客户端完成绑定。') + return + } + try { + const { data } = await http.post('/auth/sso/prepare-binding', null, { + params: { nativeApp: false, returnUrl: '/next/account' }, + }) + window.location.assign(String(data.loginUrl)) + } catch (error) { + message.error(apiErrorMessage(error)) + } + } + + async function copyRecoveryCodes() { + try { + await navigator.clipboard.writeText(recoveryCodes.join('\n')) + message.success('恢复码已复制到剪贴板') + } catch { + message.warning('浏览器未允许复制,请手动保存恢复码。') + } + } + + const data = securityQuery.data + + return ( +
+ {user?.displayName?.slice(0, 1) ?? '用'}} + /> + +
+
密码登录保留恢复路径
+ +
动态验证码{data?.totpEnabled ? '已启用' : '可选增强'}
+ +
统一身份{data?.sso.isBound ? '已绑定' : '可选绑定'}
+
+ + {securityQuery.isError && ( + void securityQuery.refetch()}>重新加载} /> + )} + +
+
+
账户身份

{user?.displayName ?? '当前用户'}

账号已启用
+ + {user?.userName ?? '—'} +
{user?.roles.map((role) => {role})}
+ {scopeNames[user?.effectiveDataScope ?? ''] ?? user?.effectiveDataScope ?? '—'} +
+ +
+ +
+
动态验证码

认证器应用

{securityQuery.isLoading ? : {data?.totpEnabled ? '已启用' : '未启用'}}
+

使用 Microsoft Authenticator、Google Authenticator 等应用生成登录验证码。启用后,密码登录必须完成二次验证。

+ {!data?.totpEnabled + ? + : } +
+ +
+
单点登录

{data?.sso.providerDisplayName ?? '学校统一身份认证'}

{securityQuery.isLoading ? : {!data?.sso.enabled ? '未启用' : data.sso.isBound ? '已绑定' : '未绑定'}}
+

{!data?.sso.enabled + ? '管理员尚未启用统一身份认证。' + : data.sso.isBound + ? '当前平台账号已经关联统一身份认证,可以直接从学校登录入口进入。' + : '绑定后,即使统一身份用户名与平台账号不同,也可以进入当前账号。'}

+ {data?.sso.enabled && !data.sso.isBound && } + {data?.sso.isBound && } + {data?.sso.enabled && data.sso.callbackUrl && ( +
管理员配置参考

Valid redirect URI 必须与下列地址完全一致:

{data.sso.callbackUrl}
+ )} +
+
+ + setTotpOpen(false)} + onOk={() => void (totpStage === 'password' ? totpPasswordForm.submit() : totpCodeForm.submit())} + destroyOnHidden + > + {totpStage === 'password' ? ( +
setupMutation.mutate(values)}> + + + + ) : ( +
enableMutation.mutate(values)}> + +
手动输入密钥{totpSetup?.secret}
+ + + )} +
+ + setRecoveryOpen(false)} closable={false} maskClosable={false} keyboard={false}> + +
{recoveryCodes.map((code) => {code})}
+ +
+ + setDisableOpen(false)} onOk={() => void disableForm.submit()} destroyOnHidden> + +
disableMutation.mutate(values)}> + +
+
+ + setUnbindOpen(false)} onOk={() => void unbindForm.submit()} destroyOnHidden> + +
unbindMutation.mutate(values)}> + +
+
+
+ ) +} diff --git a/web-react/src/pages/MigrationPlaceholderPage.tsx b/web-react/src/pages/MigrationPlaceholderPage.tsx deleted file mode 100644 index 5c57d3a..0000000 --- a/web-react/src/pages/MigrationPlaceholderPage.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ApartmentOutlined } from '@ant-design/icons' -import { Button } from 'antd' -import { useLocation } from 'react-router-dom' - -const titles: Record = { - '/account': '个人账户', - '/users': '用户与权限', -} - -export default function MigrationPlaceholderPage() { - const location = useLocation() - const title = titles[location.pathname] ?? '该功能' - const legacyBase = import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255' - - return ( -
-
MIGRATION QUEUE

{title}

页面已经纳入 React 路由,业务界面将在后续迁移批次接入。

-
-
- -

继续使用现有稳定页面

-

迁移期间不提供功能不完整的替代界面。当前操作会返回 Vue 版本,React 页面通过完整验收后再切换。

- -
-
-
- ) -} diff --git a/web-react/src/pages/SsoBindPage.tsx b/web-react/src/pages/SsoBindPage.tsx new file mode 100644 index 0000000..001d39c --- /dev/null +++ b/web-react/src/pages/SsoBindPage.tsx @@ -0,0 +1,54 @@ +import { useMemo, useState } from 'react' +import { Alert, Button, Form, Input, Result } from 'antd' +import { useLocation, useNavigate } from 'react-router-dom' +import { apiErrorMessage } from '../api/http' +import BrandMark from '../components/BrandMark' +import { branding } from '../config/branding' +import { useAuthStore } from '../stores/auth' + +function safeRedirect(value: string | null) { + if (!value || !value.startsWith('/') || value.startsWith('//')) return '/dashboard' + return value.replace(/^\/next/, '') || '/dashboard' +} + +export default function SsoBindPage() { + const location = useLocation() + const navigate = useNavigate() + const bindSso = useAuthStore((state) => state.bindSso) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const query = useMemo(() => new URLSearchParams(location.search), [location.search]) + const code = query.get('code') ?? '' + + async function submit(values: { userName: string; password: string }) { + setLoading(true) + setError('') + try { + await bindSso(code, values.userName, values.password) + navigate(safeRedirect(query.get('redirect')), { replace: true }) + } catch (cause) { + setError(apiErrorMessage(cause)) + } finally { + setLoading(false) + } + } + + if (!code) { + return 返回登录} /> + } + + return ( +
+
+
{branding.shortName}
+
统一身份认证

关联已有教务账号

统一身份尚未绑定平台账号。验证一次现有账号密码后,今后即可直接登录。

+ {error && } +
void submit(values)}> + + + +
+
+
+ ) +} diff --git a/web-react/src/pages/SsoCallbackPage.tsx b/web-react/src/pages/SsoCallbackPage.tsx new file mode 100644 index 0000000..1b58932 --- /dev/null +++ b/web-react/src/pages/SsoCallbackPage.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' +import { Result, Spin } from 'antd' +import { useLocation, useNavigate } from 'react-router-dom' +import { apiErrorMessage } from '../api/http' +import BrandMark from '../components/BrandMark' +import { branding } from '../config/branding' +import { useAuthStore } from '../stores/auth' + +function safeRedirect(value: string | null) { + if (!value || !value.startsWith('/') || value.startsWith('//')) return '/dashboard' + return value.replace(/^\/next/, '') || '/dashboard' +} + +export default function SsoCallbackPage() { + const location = useLocation() + const navigate = useNavigate() + const exchangeSso = useAuthStore((state) => state.exchangeSso) + const [error, setError] = useState('') + + useEffect(() => { + const query = new URLSearchParams(location.search) + const code = query.get('code') ?? '' + const redirect = safeRedirect(query.get('redirect')) + if (!code) { + setError('统一身份认证回调缺少登录凭据,请重新登录。') + return + } + void exchangeSso(code) + .then(() => navigate(redirect, { replace: true })) + .catch((cause) => setError(apiErrorMessage(cause))) + }, [exchangeSso, location.search, navigate]) + + return ( +
+
+
{branding.shortName}
+ {error ? ( + 返回登录} /> + ) : ( +

正在完成统一身份登录

正在验证一次性凭据并恢复教务系统会话。

+ )} +
+
+ ) +} diff --git a/web-react/src/pages/UsersPage.tsx b/web-react/src/pages/UsersPage.tsx new file mode 100644 index 0000000..1071c28 --- /dev/null +++ b/web-react/src/pages/UsersPage.tsx @@ -0,0 +1,389 @@ +import { useMemo, useState } from 'react' +import { + App, + Alert, + Button, + Form, + Input, + Modal, + Result, + Select, + Space, + Table, + Tag, + Upload, +} from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { + DownloadOutlined, + EditOutlined, + KeyOutlined, + PlusOutlined, + SearchOutlined, + UploadOutlined, +} from '@ant-design/icons' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import { downloadApiFile, importExcel } from '../api/files' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './users.css' + +interface UserRow { + id: string + userName: string + displayName: string + staffNumber?: string + collegeId?: string + roles: string[] + isEnabled: boolean + lastLoginAt?: string +} + +type DataScope = 'Self' | 'Class' | 'College' | 'All' + +interface RoleOption { + name: string + description: string + dataScope: DataScope +} + +interface CollegeOption { + id: string + name: string +} + +interface CreateUserForm { + userName: string + displayName: string + password: string + staffNumber?: string + collegeId?: string + roles: string[] +} + +interface RoleForm { + staffNumber?: string + collegeId?: string + roles: string[] +} + +interface PasswordForm { + newPassword: string + confirmPassword: string +} + +const pageSize = 20 +const scopePriority: Record = { Self: 0, Class: 1, College: 2, All: 3 } +const scopeNames: Record = { + Self: '本人数据', + Class: '所带班级', + College: '所属学院', + All: '全校数据', +} + +function formatDateTime(value?: string) { + if (!value) return '尚未登录' + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }).format(new Date(value)) +} + +export default function UsersPage() { + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const currentUser = useAuthStore((state) => state.user) + const isSuperAdmin = currentUser?.roles.includes('SuperAdmin') ?? false + const [page, setPage] = useState(1) + const [draftKeyword, setDraftKeyword] = useState('') + const [keyword, setKeyword] = useState('') + const [roleName, setRoleName] = useState() + const [collegeId, setCollegeId] = useState() + const [isEnabled, setIsEnabled] = useState() + const [hasLoggedIn, setHasLoggedIn] = useState() + const [createOpen, setCreateOpen] = useState(false) + const [roleOpen, setRoleOpen] = useState(false) + const [passwordOpen, setPasswordOpen] = useState(false) + const [selectedUser, setSelectedUser] = useState(null) + const [importing, setImporting] = useState(false) + const [createForm] = Form.useForm() + const [roleForm] = Form.useForm() + const [passwordForm] = Form.useForm() + const editingRoles = Form.useWatch('roles', roleForm) ?? [] + + const metadataQuery = useQuery({ + queryKey: ['users', 'metadata'], + enabled: isSuperAdmin, + queryFn: async () => { + const [roles, colleges] = await Promise.all([ + http.get('/users/roles'), + http.get('/base-data/colleges'), + ]) + return { roles: roles.data, colleges: colleges.data } + }, + }) + + const usersQuery = useQuery({ + queryKey: ['users', { page, keyword, roleName, collegeId, isEnabled, hasLoggedIn }], + enabled: isSuperAdmin, + queryFn: async () => (await http.get<{ + items: UserRow[] + total: number + page: number + pageSize: number + }>('/users', { + params: { page, pageSize, keyword: keyword || undefined, roleName, collegeId, isEnabled, hasLoggedIn }, + })).data, + }) + + const roles = metadataQuery.data?.roles ?? [] + const colleges = metadataQuery.data?.colleges ?? [] + const effectiveScope = useMemo(() => { + const scopes = roles.filter((role) => editingRoles.includes(role.name)).map((role) => role.dataScope) + const value = scopes.reduce((current, scope) => + scopePriority[scope] > scopePriority[current] ? scope : current, 'Self') + return scopeNames[value] + }, [editingRoles, roles]) + + async function refreshUsers() { + await queryClient.invalidateQueries({ queryKey: ['users'] }) + } + + const createMutation = useMutation({ + mutationFn: (values: CreateUserForm) => http.post('/users', values), + onSuccess: async () => { + message.success('账号已创建') + setCreateOpen(false) + createForm.resetFields() + await refreshUsers() + }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + + const roleMutation = useMutation({ + mutationFn: (values: RoleForm) => http.put(`/users/${selectedUser?.id}/roles`, values), + onSuccess: async () => { + message.success('角色与数据范围已更新') + setRoleOpen(false) + await refreshUsers() + }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + + const passwordMutation = useMutation({ + mutationFn: (values: PasswordForm) => http.put(`/users/${selectedUser?.id}/password`, { + newPassword: values.newPassword, + }), + onSuccess: () => { + message.success(`已重置 ${selectedUser?.displayName ?? '该账号'} 的密码`) + setPasswordOpen(false) + passwordForm.resetFields() + }, + onError: (error) => message.error(apiErrorMessage(error)), + }) + + function submitSearch() { + setPage(1) + setKeyword(draftKeyword.trim()) + } + + function openRoleEditor(row: UserRow) { + setSelectedUser(row) + roleForm.setFieldsValue({ + staffNumber: row.staffNumber, + collegeId: row.collegeId, + roles: [...row.roles], + }) + setRoleOpen(true) + } + + function openPasswordReset(row: UserRow) { + setSelectedUser(row) + passwordForm.resetFields() + setPasswordOpen(true) + } + + function toggleStatus(row: UserRow) { + modal.confirm({ + title: row.isEnabled ? `停用 ${row.displayName}?` : `启用 ${row.displayName}?`, + content: row.isEnabled + ? '停用后该账号将不能继续登录,但历史业务数据不会删除。' + : '启用后该账号可以按照现有角色重新登录。', + okText: row.isEnabled ? '确认停用' : '确认启用', + okButtonProps: row.isEnabled ? { danger: true } : undefined, + cancelText: '取消', + async onOk() { + try { + await http.put(`/users/${row.id}/status`, { isEnabled: !row.isEnabled }) + message.success(row.isEnabled ? '账号已停用' : '账号已启用') + await refreshUsers() + } catch (error) { + message.error(apiErrorMessage(error)) + throw error + } + }, + }) + } + + async function handleImport(file: File) { + setImporting(true) + try { + const { data } = await importExcel('/users/import', file) + message.success(`导入完成:新增 ${data.created} 个,更新 ${data.updated} 个`) + await refreshUsers() + } catch (error) { + message.error(apiErrorMessage(error)) + } finally { + setImporting(false) + } + return Upload.LIST_IGNORE + } + + async function download(path: string, name: string, success?: string) { + try { + await downloadApiFile(path, name) + if (success) message.success(success) + } catch (error) { + message.error(apiErrorMessage(error)) + } + } + + const columns: ColumnsType = [ + { title: '账号', dataIndex: 'userName', width: 150, fixed: 'left' }, + { + title: '人员', key: 'identity', width: 190, + render: (_, row) =>
{row.displayName}{row.staffNumber || '未关联工号/学号'}
, + }, + { + title: '角色', dataIndex: 'roles', width: 260, + render: (values: string[]) =>
{values.map((role) => {role})}
, + }, + { title: '最后登录', dataIndex: 'lastLoginAt', width: 180, render: formatDateTime }, + { + title: '状态', dataIndex: 'isEnabled', width: 92, + render: (value: boolean) => {value ? '启用' : '停用'}, + }, + { + title: '操作', key: 'actions', width: 285, fixed: 'right', + render: (_, row) => ( + + + + + + ), + }, + ] + + if (!isSuperAdmin) { + return + } + + return ( +
+ } onClick={() => setCreateOpen(true)}>创建账号} + /> + +
+
ACCOUNT LEDGER{usersQuery.data?.total ?? 0}符合当前条件的账号
+

先限定身份与组织范围,再处理角色、状态和登录凭据。

+
+ +
+
+ } + placeholder="搜索账号、姓名、工号或角色" + onChange={(event) => setDraftKeyword(event.target.value)} + onPressEnter={submitSearch} + /> + ({ label: college.name, value: college.id }))} onChange={(value) => { setPage(1); setCollegeId(value) }} /> + { setPage(1); setHasLoggedIn(value) }} /> + +
+
+ 数据交换 + + + + + + + +
+ {(usersQuery.isError || metadataQuery.isError) && ( + void refreshUsers()}>重新加载} /> + )} + + rowKey="id" + loading={usersQuery.isLoading || metadataQuery.isLoading} + dataSource={usersQuery.data?.items ?? []} + columns={columns} + scroll={{ x: 1200 }} + pagination={{ + current: page, + pageSize, + total: usersQuery.data?.total ?? 0, + showSizeChanger: false, + showTotal: (value) => `共 ${value} 个账号`, + onChange: setPage, + }} + locale={{ emptyText: keyword || roleName || collegeId || isEnabled !== undefined || hasLoggedIn !== undefined ? '没有符合筛选条件的账号' : '还没有账号' }} + /> +
+ + setCreateOpen(false)} onOk={() => void createForm.submit()} destroyOnHidden> +
createMutation.mutate(values)}> +
+ + +
+
+ + +
+ ({ label: `${role.name} · ${role.description}`, value: role.name }))} /> +
+
+ + setRoleOpen(false)} onOk={() => void roleForm.submit()} destroyOnHidden> + {selectedUser &&
{selectedUser.displayName}{selectedUser.userName} · {selectedUser.staffNumber || '未关联工号/学号'}
} +
roleMutation.mutate(values)}> +
+ + ({ label: `${role.name} · ${role.description}`, value: role.name }))} /> + + + + + setPasswordOpen(false)} onOk={() => void passwordForm.submit()} destroyOnHidden> + {selectedUser &&
{selectedUser.displayName}{selectedUser.userName}
} + +
passwordMutation.mutate(values)}> + + ({ validator(_, value) { return !value || getFieldValue('newPassword') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) } }), + ]} + > +
+
+
+ ) +} diff --git a/web-react/src/pages/account.css b/web-react/src/pages/account.css new file mode 100644 index 0000000..2a42524 --- /dev/null +++ b/web-react/src/pages/account.css @@ -0,0 +1,46 @@ +.account-react-page { gap: 18px; } +.account-hero-avatar { width: 64px !important; height: 64px !important; display: grid !important; place-items: center; color: #173b40 !important; background: var(--teal-bright) !important; font: 700 25px var(--font-display) !important; } +.security-rail { min-height: 78px; padding: 14px 22px; display: grid; grid-template-columns: minmax(145px, auto) 1fr minmax(145px, auto) 1fr minmax(145px, auto); align-items: center; gap: 14px; color: #aeb8d4; background: linear-gradient(108deg, var(--brand-deep), var(--brand)); } +.security-rail > div { display: grid; grid-template-columns: 30px auto; gap: 2px 10px; align-items: center; } +.security-rail > div > .anticon { grid-row: 1 / 3; font-size: 23px; } +.security-rail span { color: #dce2f1; font-size: 12px; font-weight: 650; } +.security-rail small { font-size: 9px; } +.security-rail i { height: 1px; display: block; background: rgba(255,255,255,.18); position: relative; } +.security-rail i::after { content: ""; width: 5px; height: 5px; border-radius: 50%; background: #7889b5; position: absolute; right: 0; top: -2px; } +.security-rail .is-ready { color: var(--teal-bright); } +.security-rail .is-ready span { color: white; } +.account-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.security-card { min-width: 0; min-height: 292px; padding: 25px; border: 1px solid var(--line); background: var(--paper); box-shadow: var(--shadow-panel); } +.security-card > header { min-height: 58px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 17px; border-bottom: 1px solid #edf0f4; } +.security-card > header span { color: var(--ink-muted); font-size: 10px; letter-spacing: .08em; } +.security-card > header h2 { margin: 6px 0 0; color: var(--brand-deep); font: 600 20px/1.2 var(--font-display); } +.security-card > p { min-height: 66px; margin: 22px 0 18px; color: var(--ink-muted); font-size: 12px; line-height: 1.8; } +.identity-security-card { grid-row: span 2; min-height: 602px; } +.identity-security-card .ant-descriptions { margin: 20px 0; } +.identity-security-card .ant-descriptions-item { padding-bottom: 15px; } +.identity-security-card .ant-descriptions-item-label { width: 100px; color: var(--ink-muted); } +.identity-security-card .ant-descriptions-item-content { color: var(--ink-strong); font-weight: 650; } +.account-role-list { display: flex; flex-wrap: wrap; gap: 5px; } +.callback-reference { margin-top: 22px; color: var(--ink-muted); font-size: 11px; } +.callback-reference summary { color: #3c6078; cursor: pointer; font-weight: 650; } +.callback-reference p { margin: 10px 0 7px; } +.callback-reference code { display: block; padding: 10px; overflow-wrap: anywhere; color: var(--teal); background: #f4f7f9; } +.totp-secret { margin: 16px 0; padding: 13px 14px; display: grid; gap: 7px; border-left: 3px solid var(--teal); background: #f4f9f8; } +.totp-secret span { color: var(--ink-muted); font-size: 10px; } +.totp-secret code { overflow-wrap: anywhere; color: var(--brand-deep); font: 700 14px/1.5 var(--font-data); letter-spacing: .08em; } +.recovery-code-grid { margin: 18px 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } +.recovery-code-grid code { padding: 9px 11px; border: 1px solid var(--line); color: var(--brand-deep); background: #f7f9fb; font: 700 12px var(--font-data); text-align: center; } + +@media (max-width: 960px) { + .account-card-grid { grid-template-columns: 1fr; } + .identity-security-card { grid-row: auto; min-height: 0; } +} + +@media (max-width: 720px) { + .security-rail { grid-template-columns: 1fr; gap: 8px; } + .security-rail > div { grid-template-columns: 26px auto; } + .security-rail i { width: 1px; height: 14px; margin-left: 12px; } + .security-rail i::after { right: -2px; top: auto; bottom: 0; } + .security-card { min-height: 0; padding: 20px; } + .recovery-code-grid { grid-template-columns: 1fr; } +} diff --git a/web-react/src/pages/users.css b/web-react/src/pages/users.css new file mode 100644 index 0000000..9391dc1 --- /dev/null +++ b/web-react/src/pages/users.css @@ -0,0 +1,53 @@ +.users-page { gap: 16px; } +.scope-ledger { + min-height: 88px; + display: grid; + grid-template-columns: 250px 1fr; + align-items: stretch; + color: white; + background: linear-gradient(108deg, var(--brand-deep), var(--brand) 72%, #1b696d); + overflow: hidden; +} +.scope-ledger > div { padding: 15px 22px; display: grid; grid-template-columns: 1fr auto; gap: 3px 18px; border-right: 1px solid rgba(255,255,255,.14); } +.scope-ledger span { color: #8fd9d0; font: 700 9px/1.2 var(--font-data); letter-spacing: .14em; } +.scope-ledger strong { grid-row: 1 / 3; grid-column: 2; align-self: center; color: var(--teal-bright); font: 700 32px/1 var(--font-data); } +.scope-ledger small { color: #c0cae3; font-size: 10px; } +.scope-ledger p { margin: 0; padding: 0 24px; align-self: center; color: #d3d9e9; font-size: 12px; } +.users-panel { overflow: hidden; box-shadow: none; } +.user-filters { padding: 14px 16px; display: grid; grid-template-columns: minmax(230px, 1fr) repeat(4, minmax(145px, .55fr)) auto; gap: 10px; border-bottom: 1px solid var(--line); background: #fafbfd; } +.user-toolstrip { min-height: 58px; padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--line); } +.user-toolstrip > span { color: var(--ink-muted); font-size: 10px; font-weight: 700; letter-spacing: .12em; } +.identity-cell { display: grid; gap: 4px; } +.identity-cell strong { color: var(--ink-strong); font-size: 13px; } +.identity-cell span { color: var(--ink-muted); font-size: 10px; } +.role-tags { display: flex; flex-wrap: wrap; gap: 4px; } +.status-dot { display: inline-flex; align-items: center; gap: 6px; color: #87909d; font-size: 11px; } +.status-dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #aeb5bf; } +.status-dot.is-active { color: var(--teal); } +.status-dot.is-active::before { background: var(--teal); box-shadow: 0 0 0 3px var(--teal-soft); } +.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.modal-form { margin-top: 18px; } +.modal-identity { margin-bottom: 16px; padding: 13px 15px; display: grid; gap: 4px; border-left: 3px solid var(--teal); background: #f5faf9; } +.modal-identity strong { color: var(--ink-strong); } +.modal-identity span { color: var(--ink-muted); font-size: 11px; } +.password-form { margin-top: 16px; } + +@media (max-width: 1180px) { + .user-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .user-search { grid-column: span 2; } +} + +@media (max-width: 720px) { + .scope-ledger { grid-template-columns: 1fr; } + .scope-ledger > div { border-right: 0; border-bottom: 1px solid rgba(255,255,255,.14); } + .scope-ledger p { padding: 14px 22px; } + .user-filters { grid-template-columns: 1fr 1fr; } + .user-search { grid-column: 1 / -1; } + .user-toolstrip { align-items: flex-start; flex-direction: column; } + .form-grid { grid-template-columns: 1fr; gap: 0; } +} + +@media (max-width: 440px) { + .user-filters { grid-template-columns: 1fr; } + .user-search { grid-column: auto; } +} diff --git a/web-react/src/stores/auth.ts b/web-react/src/stores/auth.ts index 5fddb57..26c244e 100644 --- a/web-react/src/stores/auth.ts +++ b/web-react/src/stores/auth.ts @@ -22,6 +22,8 @@ interface AuthState { sync: () => void login: (userName: string, password: string, captchaId: string, captchaCode: string) => Promise<{ twoFactorTicket?: string }> completeTotpLogin: (twoFactorTicket: string, code: string) => Promise + exchangeSso: (code: string) => Promise + bindSso: (code: string, userName: string, password: string) => Promise refreshUser: () => Promise logout: () => void } @@ -61,6 +63,24 @@ export const useAuthStore = create((set) => ({ saveAuthSession(data) set({ token: data.token, user: data.user }) }, + exchangeSso: async (code) => { + const { data } = await http.post('/auth/sso/exchange', { + code, + isNativeApp: isNativeApp(), + }) + saveAuthSession(data) + set({ token: data.token, user: data.user }) + }, + bindSso: async (code, userName, password) => { + const { data } = await http.post('/auth/sso/bind', { + code, + userName, + password, + isNativeApp: isNativeApp(), + }) + saveAuthSession(data) + set({ token: data.token, user: data.user }) + }, refreshUser: async () => { if (!localStorage.getItem(authStorageKeys.token)) return const { data } = await http.get('/auth/me')