This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit c64801e73d6171a82c41339c1a5b77d8307c2d40 Author: Logic <[email protected]> AuthorDate: Fri May 29 01:14:54 2026 +0800 feat(web-next): restore passport parity --- web-next/app/api/account/auth/form/route.ts | 35 ++ web-next/app/api/account/auth/refresh/route.ts | 44 +++ web-next/app/api/account/session/route.ts | 24 ++ web-next/app/login/page.tsx | 4 +- .../app/passport/lock/page-submit-flow.test.tsx | 120 ++++++ web-next/app/passport/lock/page.test.tsx | 39 +- web-next/app/passport/lock/page.tsx | 73 +--- web-next/app/passport/lock/passport-lock-page.tsx | 63 +++ web-next/app/passport/login/page.test.tsx | 25 +- web-next/app/passport/login/page.tsx | 15 +- .../pages/login-form-submit-flow.test.tsx | 421 +++++++++++++++++++++ .../components/pages/login-form.submit.test.tsx | 15 +- web-next/components/pages/login-form.test.tsx | 86 +++-- web-next/components/pages/login-form.tsx | 203 ++++++---- .../pages/passport-shell-session-clear.test.tsx | 125 ++++++ web-next/components/pages/passport-shell.test.tsx | 47 ++- web-next/components/pages/passport-shell.tsx | 27 +- web-next/lib/passport-lock/view-model.test.ts | 6 +- web-next/lib/passport-lock/view-model.ts | 4 +- web-next/lib/passport-login/controller.test.ts | 108 +++++- web-next/lib/passport-login/controller.ts | 92 ++++- web-next/lib/passport-login/view-model.test.ts | 29 +- web-next/lib/passport-login/view-model.ts | 20 +- web-next/lib/session-bff.test.ts | 178 +++++++++ web-next/lib/session-bff.ts | 206 ++++++++++ 25 files changed, 1771 insertions(+), 238 deletions(-) diff --git a/web-next/app/api/account/auth/form/route.ts b/web-next/app/api/account/auth/form/route.ts new file mode 100644 index 0000000000..2192593721 --- /dev/null +++ b/web-next/app/api/account/auth/form/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + applySessionCookies, + buildBackendApiUrl, + clearSessionCookies, + readJsonPayload, + sanitizeSessionPayload +} from '@/lib/session-bff'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + const upstream = await fetch(buildBackendApiUrl('/account/auth/form'), { + method: 'POST', + headers: { + 'Content-Type': request.headers.get('Content-Type') || 'application/json', + ...(request.headers.get('Accept-Language') ? { 'Accept-Language': request.headers.get('Accept-Language') as string } : {}) + }, + body: await request.text(), + cache: 'no-store' + }); + const payload = await readJsonPayload(upstream); + const response = NextResponse.json(sanitizeSessionPayload(payload), { status: upstream.status }); + + if (upstream.ok && payload.code === 0 && payload.data) { + applySessionCookies(response, { + token: typeof payload.data.token === 'string' ? payload.data.token : undefined, + refreshToken: typeof payload.data.refreshToken === 'string' ? payload.data.refreshToken : undefined + }); + } else { + clearSessionCookies(response); + } + + return response; +} diff --git a/web-next/app/api/account/auth/refresh/route.ts b/web-next/app/api/account/auth/refresh/route.ts new file mode 100644 index 0000000000..f55252ab21 --- /dev/null +++ b/web-next/app/api/account/auth/refresh/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + HB_UI_REFRESH_COOKIE, + applySessionCookies, + buildBackendApiUrl, + clearSessionCookies, + readJsonPayload, + readSessionCookieValue, + sanitizeSessionPayload +} from '@/lib/session-bff'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + const refreshToken = readSessionCookieValue(request, HB_UI_REFRESH_COOKIE); + if (!refreshToken) { + const response = NextResponse.json({ code: 401, msg: 'Missing refresh session', data: null }, { status: 401 }); + clearSessionCookies(response); + return response; + } + + const upstream = await fetch(buildBackendApiUrl('/account/auth/refresh'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(request.headers.get('Accept-Language') ? { 'Accept-Language': request.headers.get('Accept-Language') as string } : {}) + }, + body: JSON.stringify({ token: refreshToken }), + cache: 'no-store' + }); + const payload = await readJsonPayload(upstream); + const response = NextResponse.json(sanitizeSessionPayload(payload), { status: upstream.status }); + + if (upstream.ok && payload.code === 0 && payload.data) { + applySessionCookies(response, { + token: typeof payload.data.token === 'string' ? payload.data.token : undefined, + refreshToken: typeof payload.data.refreshToken === 'string' ? payload.data.refreshToken : refreshToken + }); + } else { + clearSessionCookies(response); + } + + return response; +} diff --git a/web-next/app/api/account/session/route.ts b/web-next/app/api/account/session/route.ts new file mode 100644 index 0000000000..dabd4ce88e --- /dev/null +++ b/web-next/app/api/account/session/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + HB_UI_ACCESS_COOKIE, + HB_UI_REFRESH_COOKIE, + clearSessionCookies, + readSessionCookieValue +} from '@/lib/session-bff'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + return NextResponse.json({ + authenticated: Boolean( + readSessionCookieValue(request, HB_UI_ACCESS_COOKIE) || + readSessionCookieValue(request, HB_UI_REFRESH_COOKIE) + ) + }); +} + +export async function DELETE() { + const response = NextResponse.json({ authenticated: false }); + clearSessionCookies(response); + return response; +} diff --git a/web-next/app/login/page.tsx b/web-next/app/login/page.tsx index cba692aacc..6d0a0f3833 100644 --- a/web-next/app/login/page.tsx +++ b/web-next/app/login/page.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { buildCompatRedirectTarget, type SearchParamsRecord } from '../../lib/compat/search-params'; +import { buildLoginCompatRouteUrl, type SearchParamsRecord } from '../../lib/passport-login/controller'; export default async function LoginAliasPage({ searchParams @@ -7,5 +7,5 @@ export default async function LoginAliasPage({ searchParams?: Promise<SearchParamsRecord>; }) { const resolvedSearchParams = await searchParams; - redirect(buildCompatRedirectTarget('/passport/login', resolvedSearchParams)); + redirect(buildLoginCompatRouteUrl(resolvedSearchParams)); } diff --git a/web-next/app/passport/lock/page-submit-flow.test.tsx b/web-next/app/passport/lock/page-submit-flow.test.tsx new file mode 100644 index 0000000000..2fb20d1495 --- /dev/null +++ b/web-next/app/passport/lock/page-submit-flow.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTranslatorMock } from '../../../test/i18n-test-helper'; +import { HB_UI_SESSION_USER_KEY } from '../../../lib/session-client'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockState = vi.hoisted(() => ({ + routerReplace: vi.fn() +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: mockState.routerReplace + }) +})); + +vi.mock('next/image', () => ({ + default: ({ alt, src, priority: _priority, ...props }: any) => React.createElement('img', { alt, src, ...props }) +})); + +vi.mock('@/components/providers/i18n-provider', () => ({ + useI18n: () => ({ + t: createTranslatorMock({ locale: 'zh-CN' }), + locale: 'zh-CN', + locales: [ + { code: 'en-US', labelKey: 'settings.system-config.locale.en_US', abbr: 'gb' }, + { code: 'zh-CN', labelKey: 'settings.system-config.locale.zh_CN', abbr: 'cn' } + ], + setLocale: vi.fn() + }) +})); + +describe('passport lock submit flow', () => { + let container: HTMLDivElement; + let root: Root; + + async function mountPage() { + const { default: PassportLockPage } = await import('./passport-lock-page'); + await act(async () => { + root.render(<PassportLockPage />); + await Promise.resolve(); + }); + } + + function setPassword(value: string) { + const input = container.querySelector('[data-hz-passport-lock-password-input="shared"]') as HTMLInputElement | null; + if (!input) throw new Error('Missing lock password input'); + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mockState.routerReplace.mockReset(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + document.cookie = 'hb_ui_session=; Max-Age=0; path=/'; + }); + + it('blocks empty unlock drafts and navigates to the workbench after a password is entered', async () => { + window.sessionStorage.setItem( + HB_UI_SESSION_USER_KEY, + JSON.stringify({ name: 'ops-admin', avatar: './assets/img/avatar.svg', email: 'administrator', role: 'ADMIN' }) + ); + document.cookie = 'hb_ui_session=1; path=/'; + await mountPage(); + + expect(container.querySelector('[data-hz-passport-lock-submit-lifecycle="angular-mark-dirty-required-then-dashboard"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-lock-submit-lifecycle-contract="angular-mark-dirty-required-then-dashboard"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-lock-required-mode-contract="angular-required-no-trim"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-lock-required-mode="angular-required-no-trim"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-session-clear-contract="angular-lock-preserve-session"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-session-clear-enabled="false"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-session-clear-lifecycle="angular-lock-preserve-session"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-session-clear-scope="client-marker-user-snapshot-preserved"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-lock-avatar-contract="angular-settings-user-avatar"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-lock-avatar-source="settings-user-avatar"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-lock-avatar-img="settings-user-avatar"]')).not.toBeNull(); + expect((container.querySelector('[data-hz-passport-lock-avatar-img="settings-user-avatar"]') as HTMLImageElement).getAttribute('src')).toBe('./assets/img/avatar.svg'); + expect((container.querySelector('[data-hz-passport-lock-avatar-img="settings-user-avatar"]') as HTMLImageElement).getAttribute('alt')).toBe('ops-admin'); + expect(window.sessionStorage.getItem(HB_UI_SESSION_USER_KEY)).toContain('"name":"ops-admin"'); + expect(document.cookie).toContain('hb_ui_session=1'); + expect(container.querySelector('[data-hz-passport-lock-submit-state="disabled"]')).not.toBeNull(); + + await act(async () => { + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + + expect(mockState.routerReplace).not.toHaveBeenCalled(); + + await act(async () => { + setPassword(' '); + await Promise.resolve(); + }); + + expect(container.querySelector('[data-hz-passport-lock-submit-state="ready"]')).not.toBeNull(); + + await act(async () => { + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + + expect(mockState.routerReplace).toHaveBeenCalledWith('/overview'); + }, 30000); +}); diff --git a/web-next/app/passport/lock/page.test.tsx b/web-next/app/passport/lock/page.test.tsx index a58a5bf10f..d5fe6d9548 100644 --- a/web-next/app/passport/lock/page.test.tsx +++ b/web-next/app/passport/lock/page.test.tsx @@ -10,12 +10,12 @@ vi.mock('next/navigation', () => ({ })); vi.mock('next/image', () => ({ - default: ({ alt, src, priority: _priority, ...props }: any) => <img alt={alt} src={src} {...props} /> + default: ({ alt, src, priority: _priority, ...props }: any) => React.createElement('img', { alt, src, ...props }) })); vi.mock('@/components/providers/i18n-provider', () => ({ useI18n: () => ({ - t: createTranslatorMock(), + t: createTranslatorMock({ locale: 'zh-CN' }), locale: 'zh-CN', locales: [ { code: 'en-US', labelKey: 'settings.system-config.locale.en_US', abbr: '🇬🇧' }, @@ -26,11 +26,15 @@ vi.mock('@/components/providers/i18n-provider', () => ({ })); vi.mock('../../../components/pages/passport-shell', () => ({ - PassportShell: ({ children, panelClassName }: any) => ( + PassportShell: ({ children, panelClassName, sessionLifecycle }: any) => ( <div data-login-shell="passport" data-passport-shell="true" data-passport-shell-panel-class={panelClassName} + data-passport-session-clear-contract={sessionLifecycle === 'preserve-on-lock' ? 'angular-lock-preserve-session' : 'angular-token-service-clear-on-passport-entry'} + data-passport-session-clear-enabled={sessionLifecycle === 'preserve-on-lock' ? 'false' : 'true'} + data-hz-passport-session-clear-lifecycle={sessionLifecycle === 'preserve-on-lock' ? 'angular-lock-preserve-session' : 'angular-token-service-clear-on-passport-entry'} + data-hz-passport-session-clear-scope={sessionLifecycle === 'preserve-on-lock' ? 'client-marker-user-snapshot-preserved' : 'client-marker-user-snapshot'} style={{ backgroundImage: "url('/assets/bg.png')" }} > <div>Apache HertzBeat™</div> @@ -63,14 +67,37 @@ describe('passport lock page', () => { const html = renderToStaticMarkup(<PassportLockPage />); expect(html).toContain('data-passport-shell="true"'); + expect(html).toContain('data-passport-session-clear-contract="angular-lock-preserve-session"'); + expect(html).toContain('data-passport-session-clear-enabled="false"'); + expect(html).toContain('data-hz-passport-session-clear-lifecycle="angular-lock-preserve-session"'); + expect(html).toContain('data-hz-passport-session-clear-scope="client-marker-user-snapshot-preserved"'); expect(html).toContain('data-passport-panel="true"'); expect(html).toContain('data-passport-lock="true"'); expect(html).toContain('data-passport-lock-panel="angular-wide"'); + expect(html).toContain('data-passport-lock-panel-owner="hertzbeat-ui-passport-lock"'); + expect(html).toContain('data-passport-lock-avatar-contract="angular-settings-user-avatar"'); + expect(html).toContain('data-passport-lock-session-contract="angular-lock-preserve-session"'); + expect(html).toContain('data-passport-lock-submit-lifecycle-contract="angular-mark-dirty-required-then-dashboard"'); + expect(html).toContain('data-passport-lock-submit-lifecycle-owner="hertzbeat-ui-passport-lock"'); + expect(html).toContain('data-passport-lock-redirect-contract="angular-dashboard-next-overview"'); + expect(html).toContain('data-passport-lock-required-mode-contract="angular-required-no-trim"'); + expect(html).toContain('data-passport-lock-required-mode-owner="hertzbeat-ui-passport-lock"'); + expect(html).toContain('data-hz-ui="passport-lock-surface"'); + expect(html).toContain('data-hz-passport-lock-owner="hertzbeat-ui-passport-lock"'); + expect(html).toContain('data-hz-passport-lock-density="angular-lock-card"'); + expect(html).toContain('data-hz-passport-lock-submit-lifecycle="angular-mark-dirty-required-then-dashboard"'); + expect(html).toContain('data-hz-passport-lock-required-fields="password"'); + expect(html).toContain('data-hz-passport-lock-required-mode="angular-required-no-trim"'); + expect(html).toContain('data-hz-passport-lock-redirect="angular-dashboard-next-overview"'); + expect(html).toContain('data-hz-passport-lock-submit-disabled="angular-invalid-disabled"'); + expect(html).toContain('data-hz-passport-lock-avatar="angular-floating"'); + expect(html).toContain('data-hz-passport-lock-avatar-source="fallback-user-icon"'); + expect(html).toContain('data-hz-passport-lock-submit-state="disabled"'); expect(html).toContain('max-w-[712px]'); expect(html).toContain('rounded-none'); - expect(html).toContain('Unlock'); - expect(html).toContain('Enter Any To Unlock'); + expect(html).toContain('解除锁定'); + expect(html).toContain('输入任意解锁'); expect(html).toContain('/assets/bg.png'); expect(html).toContain('Apache HertzBeat™'); - }, 15000); + }, 30000); }); diff --git a/web-next/app/passport/lock/page.tsx b/web-next/app/passport/lock/page.tsx index baccd086db..25d490a279 100644 --- a/web-next/app/passport/lock/page.tsx +++ b/web-next/app/passport/lock/page.tsx @@ -1,71 +1,6 @@ -'use client'; +import React from 'react'; +import PassportLockPage from './passport-lock-page'; -import React, { FormEvent, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { LockKeyhole, UserRound } from 'lucide-react'; -import { ObservabilityStatusState } from '@/components/observability'; -import { useI18n } from '@/components/providers/i18n-provider'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { validateUnlockPassword } from '../../../lib/passport-lock/view-model'; -import { PassportPanel, PassportShell } from '../../../components/pages/passport-shell'; - -export default function PassportLockPage() { - const { t } = useI18n(); - const router = useRouter(); - const [password, setPassword] = useState(''); - const [error, setError] = useState<string | null>(null); - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - const validationError = validateUnlockPassword(password, t); - if (validationError) { - setError(validationError); - return; - } - setError(null); - router.replace('/overview'); - } - - return ( - <PassportShell panelClassName="max-w-[712px] lg:max-w-[712px]"> - <PassportPanel - className="mx-auto min-h-[334px] max-w-[712px] rounded-none border-[var(--ops-border-color)] bg-[#101217] px-6 py-14 shadow-none lg:px-[120px]" - > - <div data-passport-lock-panel="angular-wide" className="mx-auto flex min-h-[220px] max-w-[320px] flex-col justify-center"> - <div className="text-center"> - <div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-white/75 text-[var(--ops-surface-base)] shadow-none"> - <UserRound size={28} /> - </div> - <div className="mt-6 text-[16px] font-semibold text-[var(--ops-text-primary)]">{t('app.lock')}</div> - </div> - <form onSubmit={handleSubmit} className="mt-6 space-y-5" data-passport-lock="true"> - <div className="relative"> - <LockKeyhole - size={16} - className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--ops-text-tertiary)]" - /> - <Input - placeholder={t('app.lock.placeholder')} - type="password" - value={password} - onChange={e => setPassword(e.target.value)} - className="h-9 border-[var(--ops-border-color)] bg-[var(--ops-surface-base)] pl-10" - /> - </div> - <div className="flex justify-end"> - <Button className="min-w-[76px]" size="sm" variant="primary" type="submit"> - {t('app.lock')} - </Button> - </div> - </form> - {error ? ( - <div className="mt-4"> - <ObservabilityStatusState title={t('common.failed')} copy={error} tone="danger" /> - </div> - ) : null} - </div> - </PassportPanel> - </PassportShell> - ); +export default function PassportLockRoutePage() { + return <PassportLockPage />; } diff --git a/web-next/app/passport/lock/passport-lock-page.tsx b/web-next/app/passport/lock/passport-lock-page.tsx new file mode 100644 index 0000000000..a6efdaf5d0 --- /dev/null +++ b/web-next/app/passport/lock/passport-lock-page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import React, { FormEvent, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { HzPassportLockSurface } from '@hertzbeat/ui'; +import { useI18n } from '@/components/providers/i18n-provider'; +import { validateUnlockPassword } from '../../../lib/passport-lock/view-model'; +import { readClientSessionUserSnapshot } from '../../../lib/session-client'; +import { PassportPanel, PassportShell } from '../../../components/pages/passport-shell'; + +export default function PassportLockPage() { + const { t } = useI18n(); + const router = useRouter(); + const [sessionUser] = useState(() => readClientSessionUserSnapshot()); + const [password, setPassword] = useState(''); + const [error, setError] = useState<string | null>(null); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const validationError = validateUnlockPassword(password, t); + if (validationError) { + setError(validationError); + return; + } + setError(null); + router.replace('/overview'); + } + + return ( + <PassportShell panelClassName="max-w-[712px] lg:max-w-[712px]" sessionLifecycle="preserve-on-lock"> + <PassportPanel + className="mx-auto min-h-[334px] max-w-[712px] rounded-none border-[var(--ops-border-color)] bg-[#101217] px-6 py-14 shadow-none lg:px-[120px]" + > + <HzPassportLockSurface + title={t('app.lock')} + passwordLabel={t('app.lock.placeholder')} + passwordPlaceholder={t('app.lock.placeholder')} + buttonLabel={t('app.lock')} + password={password} + avatarSrc={sessionUser?.avatar} + avatarAlt={sessionUser?.name || t('app.lock')} + error={error} + disabled={password.length === 0} + onPasswordChange={value => { + setPassword(value); + if (error) setError(null); + }} + onSubmit={handleSubmit} + data-passport-lock="true" + data-passport-lock-panel="angular-wide" + data-passport-lock-panel-owner="hertzbeat-ui-passport-lock" + data-passport-lock-avatar-contract="angular-settings-user-avatar" + data-passport-lock-session-contract="angular-lock-preserve-session" + data-passport-lock-submit-lifecycle-contract="angular-mark-dirty-required-then-dashboard" + data-passport-lock-submit-lifecycle-owner="hertzbeat-ui-passport-lock" + data-passport-lock-redirect-contract="angular-dashboard-next-overview" + data-passport-lock-required-mode-contract="angular-required-no-trim" + data-passport-lock-required-mode-owner="hertzbeat-ui-passport-lock" + /> + </PassportPanel> + </PassportShell> + ); +} diff --git a/web-next/app/passport/login/page.test.tsx b/web-next/app/passport/login/page.test.tsx index 667793e281..369bc386a0 100644 --- a/web-next/app/passport/login/page.test.tsx +++ b/web-next/app/passport/login/page.test.tsx @@ -1,17 +1,38 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; +import type { PassportLoginRouteState } from '@/lib/passport-login/controller'; vi.mock('@/components/pages/login-form', () => ({ - LoginForm: () => <div data-login-form="true">passport login form</div> + LoginForm: ({ initialRouteState }: { initialRouteState?: PassportLoginRouteState }) => ( + <div data-login-form="true" data-redirect-target={initialRouteState?.redirectTarget}> + passport login form + </div> + ) })); describe('passport login page', () => { it('renders the shared login form surface', async () => { const { default: PassportLoginPage } = await import('./page'); - const html = renderToStaticMarkup(<PassportLoginPage />); + const html = renderToStaticMarkup(await PassportLoginPage()); expect(html).toContain('data-login-form="true"'); expect(html).toContain('passport login form'); + expect(html).toContain('data-redirect-target="/"'); + }); + + it('passes the normalized redirect route state to the login form', async () => { + const { default: PassportLoginPage } = await import('./page'); + const { LoginForm } = await import('@/components/pages/login-form'); + const initialRouteState = { redirectTarget: '/monitors?app=website' }; + const mockedFormHtml = renderToStaticMarkup(<LoginForm initialRouteState={initialRouteState} />); + const html = renderToStaticMarkup( + await PassportLoginPage({ + searchParams: Promise.resolve({ redirect: '/monitors?app=website' }) + }) + ); + + expect(mockedFormHtml).toContain('data-redirect-target="/monitors?app=website"'); + expect(html).toContain('data-redirect-target="/monitors?app=website"'); }); }); diff --git a/web-next/app/passport/login/page.tsx b/web-next/app/passport/login/page.tsx index dcd4ae0942..3cc7e68f41 100644 --- a/web-next/app/passport/login/page.tsx +++ b/web-next/app/passport/login/page.tsx @@ -1,10 +1,21 @@ import React, { Suspense } from 'react'; import { LoginForm } from '@/components/pages/login-form'; +import { + readPassportLoginRouteState, + type PassportLoginSearchParams +} from '@/lib/passport-login/controller'; + +export default async function PassportLoginPage({ + searchParams +}: { + searchParams?: Promise<PassportLoginSearchParams>; +} = {}) { + const resolvedSearchParams = await searchParams; + const routeState = readPassportLoginRouteState(resolvedSearchParams); -export default function PassportLoginPage() { return ( <Suspense fallback={null}> - <LoginForm /> + <LoginForm initialRouteState={routeState} /> </Suspense> ); } diff --git a/web-next/components/pages/login-form-submit-flow.test.tsx b/web-next/components/pages/login-form-submit-flow.test.tsx new file mode 100644 index 0000000000..4dcc42ac01 --- /dev/null +++ b/web-next/components/pages/login-form-submit-flow.test.tsx @@ -0,0 +1,421 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTranslatorMock } from '../../test/i18n-test-helper'; +import type { PassportLoginRouteState } from '../../lib/passport-login/controller'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockState = vi.hoisted(() => ({ + resetWorkbenchLoadCache: vi.fn(), + routerReplace: vi.fn() +})); + +const setLocale = vi.fn(async () => {}); +const t = createTranslatorMock(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: mockState.routerReplace + }) +})); + +vi.mock('next/image', () => ({ + default: ({ alt, src, priority: _priority, ...props }: any) => React.createElement('img', { alt, src, ...props }) +})); + +vi.mock('../providers/i18n-provider', () => ({ + useI18n: () => ({ + t, + locale: 'en-US', + locales: [ + { code: 'en-US', labelKey: 'settings.system-config.locale.en_US', abbr: 'gb' }, + { code: 'zh-CN', labelKey: 'settings.system-config.locale.zh_CN', abbr: 'cn' }, + { code: 'ja-JP', labelKey: 'settings.system-config.locale.ja-JP', abbr: 'jp' } + ], + setLocale + }) +})); + +vi.mock('../ui/button', () => ({ + Button: ({ children, ...props }: any) => <button {...props}>{children}</button> +})); + +vi.mock('../ui/input', () => ({ + Input: (props: any) => <input {...props} /> +})); + +vi.mock('../../lib/workbench-load-cache', () => ({ + resetWorkbenchLoadCache: mockState.resetWorkbenchLoadCache +})); + +describe('login form submit copy', () => { + let container: HTMLDivElement; + let root: Root; + + async function mountForm(initialRouteState: PassportLoginRouteState = { redirectTarget: '/' }) { + const { LoginForm } = await import('./login-form'); + await act(async () => { + root.render(<LoginForm initialRouteState={initialRouteState} />); + await Promise.resolve(); + }); + } + + function setInputValue(index: number, value: string) { + const input = container.querySelectorAll('input')[index] as HTMLInputElement | undefined; + if (!input) throw new Error(`Missing input index ${index}`); + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + + async function flushAsyncWork(turns = 8) { + for (let index = 0; index < turns; index += 1) { + await Promise.resolve(); + } + } + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mockState.resetWorkbenchLoadCache.mockReset(); + mockState.routerReplace.mockReset(); + setLocale.mockClear(); + window.localStorage.clear(); + window.sessionStorage.clear(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.unstubAllGlobals(); + }); + + it('uses the BFF cookie session without writing access or refresh tokens to localStorage', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: { authenticated: true, tokenBoundary: 'bff-cookie', role: 'ADMIN' } }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: { locale: 'zh_CN' } }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: [] }) + }); + + await mountForm({ redirectTarget: '/monitors?app=website' }); + + expect(container.querySelector('[data-hz-ui="passport-login-action-frame"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-submit-lifecycle="angular-required-default-warning-session-bootstrap-redirect"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-token-boundary="bff-cookie-no-localstorage"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-session-bootstrap="angular-startup-load-after-success"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-session-user-name="angular-raw-identifier"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-startup-failure="angular-exception-500"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-login-startup-failure-contract="angular-exception-500"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-login-session-user-name-contract="angular-raw-identifier"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-required-mode="angular-required-no-trim"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-login-required-mode-contract="angular-required-no-trim"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-redirect="angular-referrer-non-passport-fallback"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-redirect-fallback="angular-root-fallback"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-login-redirect-fallback-contract="angular-root-fallback"]')).not.toBeNull(); + + await act(async () => { + setInputValue(0, 'ops-admin'); + setInputValue(1, 'custom-secret'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(12); + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/account/auth/form', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 0, identifier: 'ops-admin', credential: 'custom-secret' }) + }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/config/system', + expect.objectContaining({ + cache: 'no-store', + credentials: 'same-origin', + headers: expect.not.objectContaining({ + Authorization: expect.any(String) + }) + }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/apps/hierarchy?lang=zh-CN', + expect.objectContaining({ + cache: 'no-store', + credentials: 'same-origin', + headers: expect.not.objectContaining({ + Authorization: expect.any(String) + }) + }) + ); + expect(window.localStorage.getItem('Authorization')).toBeNull(); + expect(window.localStorage.getItem('refresh-token')).toBeNull(); + expect(window.sessionStorage.getItem('HB_UI_SESSION_USER')).toContain('"name":"ops-admin"'); + expect(window.sessionStorage.getItem('HB_UI_SESSION_USER')).toContain('"role":"ADMIN"'); + expect(window.sessionStorage.getItem('HB_UI_SESSION_USER')).not.toContain('token'); + expect(window.sessionStorage.getItem('HB_ABOUT_AUTO_SHOW_AFTER_LOGIN')).toBe('true'); + expect(mockState.resetWorkbenchLoadCache).toHaveBeenCalledTimes(1); + expect(mockState.routerReplace).toHaveBeenCalledWith('/monitors?app=website'); + }, 60000); + + it('routes to the Angular startup failure page when post-login hierarchy bootstrap fails', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: { authenticated: true, tokenBoundary: 'bff-cookie', role: 'ADMIN' } }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: { locale: 'zh_CN' } }) + }) + .mockRejectedValueOnce(new Error('hierarchy failed')); + + await mountForm({ redirectTarget: '/monitors?app=website' }); + + await act(async () => { + setInputValue(0, 'ops-admin'); + setInputValue(1, 'custom-secret'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(12); + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/apps/hierarchy?lang=zh-CN', + expect.objectContaining({ + cache: 'no-store', + credentials: 'same-origin' + }) + ); + expect(window.sessionStorage.getItem('HB_UI_SESSION_USER')).toContain('"name":"ops-admin"'); + expect(window.sessionStorage.getItem('HB_ABOUT_AUTO_SHOW_AFTER_LOGIN')).toBeNull(); + expect(mockState.routerReplace).toHaveBeenCalledWith('/exception/500'); + expect(mockState.routerReplace).not.toHaveBeenCalledWith('/monitors?app=website'); + }, 60000); + + it('keeps Angular default-password warning as the first submit instead of posting immediately', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + + await mountForm(); + + await act(async () => { + setInputValue(0, 'admin'); + setInputValue(1, 'hertzbeat'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.querySelector('[data-hz-ui="passport-login-notice"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-notice-link="account-modify"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-default-password="angular-first-submit-warning"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-default-password-lifecycle="angular-sticky-until-submit"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-login-default-password-lifecycle-contract="angular-sticky-until-submit"]')).not.toBeNull(); + }, 60000); + + it('keeps the Angular default-password notice sticky after the credential changes', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ code: 1, msg: 'bad credential' }) + }); + + await mountForm(); + + await act(async () => { + setInputValue(0, 'admin'); + setInputValue(1, 'hertzbeat'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.querySelector('[data-hz-ui="passport-login-notice"]')).not.toBeNull(); + + await act(async () => { + setInputValue(1, 'custom-secret'); + await flushAsyncWork(); + }); + + expect(container.querySelector('[data-hz-ui="passport-login-notice"]')).not.toBeNull(); + + await act(async () => { + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(12); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-hz-ui="passport-login-notice"]')).not.toBeNull(); + expect(container.textContent).toContain('bad credential'); + }, 60000); + + it('keeps Angular credential required-field validation on the client before posting', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + + await mountForm(); + + await act(async () => { + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain('Please enter your username'); + expect(container.querySelector('[data-hz-ui="passport-login-validation-notice"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-login-validation-density="angular-error-alert"]')).not.toBeNull(); + + await act(async () => { + setInputValue(0, 'ops-admin'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain('Please enter password'); + }, 60000); + + it('allows Angular required whitespace credentials through without trimming before posting', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ code: 1, msg: 'bad credential' }) + }); + + await mountForm(); + + await act(async () => { + setInputValue(0, 'ops-admin'); + setInputValue(1, ' '); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(12); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/account/auth/form', + expect.objectContaining({ + body: JSON.stringify({ type: 0, identifier: 'ops-admin', credential: ' ' }) + }) + ); + expect(container.textContent).toContain('bad credential'); + }, 60000); + + it('keeps Angular post-login user snapshot name as the raw submitted identifier', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: { authenticated: true, tokenBoundary: 'bff-cookie', role: 'ADMIN' } }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: { locale: 'zh_CN' } }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ code: 0, data: [] }) + }); + + await mountForm(); + + await act(async () => { + setInputValue(0, ' ops-admin '); + setInputValue(1, 'custom-secret'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(12); + }); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/account/auth/form', + expect.objectContaining({ + body: JSON.stringify({ type: 0, identifier: ' ops-admin ', credential: 'custom-secret' }) + }) + ); + expect(window.sessionStorage.getItem('HB_UI_SESSION_USER')).toContain('"name":" ops-admin "'); + expect(mockState.routerReplace).toHaveBeenCalledWith('/'); + }, 60000); + + it('renders localized loading and fallback login errors', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + let resolveLoginResponse: ((value: unknown) => void) | undefined; + const loginResponse = new Promise(resolve => { + resolveLoginResponse = resolve; + }); + fetchMock.mockReturnValueOnce(loginResponse); + + await mountForm(); + + await act(async () => { + setInputValue(0, 'ops-admin'); + setInputValue(1, 'custom-secret'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(); + }); + + expect((container.querySelector('button[type="submit"]') as HTMLButtonElement | null)?.textContent).toContain('Logging in'); + + await act(async () => { + resolveLoginResponse?.({ + ok: false, + status: 503, + json: async () => ({ code: 1 }) + }); + await loginResponse; + await flushAsyncWork(12); + }); + + expect(container.textContent).toContain('Login failed: 503'); + expect(mockState.routerReplace).not.toHaveBeenCalled(); + }, 60000); + + it('renders the localized generic fallback when login throws a non-Error value', async () => { + const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; + fetchMock.mockRejectedValueOnce('network-cancelled'); + + await mountForm(); + + await act(async () => { + setInputValue(0, 'ops-admin'); + setInputValue(1, 'custom-secret'); + (container.querySelector('form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flushAsyncWork(12); + }); + + expect(container.textContent).toContain('Login failed. Check username or password.'); + expect(mockState.routerReplace).not.toHaveBeenCalled(); + }, 60000); +}); diff --git a/web-next/components/pages/login-form.submit.test.tsx b/web-next/components/pages/login-form.submit.test.tsx index f347798c83..28f21a60cd 100644 --- a/web-next/components/pages/login-form.submit.test.tsx +++ b/web-next/components/pages/login-form.submit.test.tsx @@ -90,13 +90,13 @@ describe('login form submit flow', () => { vi.unstubAllGlobals(); }); - it('persists tokens, warms bootstrap config, and restores the guarded return path after login', async () => { + it('uses the BFF cookie session, warms bootstrap config, and restores the guarded return path after login', async () => { const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>; fetchMock .mockResolvedValueOnce({ ok: true, status: 200, - json: async () => ({ code: 0, data: { token: 'access-token', refreshToken: 'refresh-token' } }) + json: async () => ({ code: 0, data: { authenticated: true, tokenBoundary: 'bff-cookie' } }) }) .mockResolvedValueOnce({ ok: true, @@ -130,13 +130,14 @@ describe('login form submit flow', () => { '/api/config/system', expect.objectContaining({ cache: 'no-store', - headers: expect.objectContaining({ - Authorization: 'Bearer access-token' + credentials: 'same-origin', + headers: expect.not.objectContaining({ + Authorization: expect.any(String) }) }) ); - expect(window.localStorage.getItem('Authorization')).toBe('access-token'); - expect(window.localStorage.getItem('refresh-token')).toBe('refresh-token'); + expect(window.localStorage.getItem('Authorization')).toBeNull(); + expect(window.localStorage.getItem('refresh-token')).toBeNull(); expect(mockState.routerReplace).toHaveBeenCalledWith('/monitors?app=website'); }, 15000); @@ -146,7 +147,7 @@ describe('login form submit flow', () => { .mockResolvedValueOnce({ ok: true, status: 200, - json: async () => ({ code: 0, data: { token: 'access-token', refreshToken: 'refresh-token' } }) + json: async () => ({ code: 0, data: { authenticated: true, tokenBoundary: 'bff-cookie' } }) }) .mockResolvedValueOnce({ ok: true, diff --git a/web-next/components/pages/login-form.test.tsx b/web-next/components/pages/login-form.test.tsx index 0b0a9bc157..fb4100d697 100644 --- a/web-next/components/pages/login-form.test.tsx +++ b/web-next/components/pages/login-form.test.tsx @@ -6,36 +6,35 @@ import { resolve } from 'node:path'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; -const ZH_CN_LABEL = 'Simplified Chinese(zh_CN)'; +const ZH_CN_LABEL = '简体中文(zh_CN)'; const JA_JP_LABEL = 'Japanese(ja_JP)'; const LOGIN_NOTICE_COPY = '\u767b\u5f55\u6210\u529f\u540e\u4f1a\u81ea\u52a8\u6062\u590d\u5f53\u524d\u5de5\u4f5c\u53f0\u4f1a\u8bdd\uff0c\u5e76\u5728\u9700\u8981\u65f6\u5c1d\u8bd5\u5237\u65b0\u4ee4\u724c\u3002'; -const HERO_TITLE = 'Open-source enterprise observability for private operations'; -const HERO_LEAD = 'Collectors, monitoring templates, entities, metrics, logs, and traces'; -const HERO_FOCUS = 'Handle alerts and close issues inside HertzBeat'; +const HERO_TITLE = '开源私有化企业运维可观测平台'; +const HERO_LEAD = '采集器、监控模板、实体、指标、日志和链路'; +const HERO_FOCUS = '处理告警并关闭问题'; const HERO_BODY = - 'Collect metrics from applications, databases, operating systems, middleware, and network devices without sending data outside your deployment.'; -const LOGIN_HEADING = 'Sign In HertzBeat'; -const USERNAME_PROMPT = 'Please enter your username'; -const PASSWORD_PROMPT = 'Please enter password'; -const LOGIN_BUTTON = 'Login'; + '通过采集器接入应用、数据库、操作系统、中间件和网络设备指标,数据留在私有化部署内。'; +const LOGIN_HEADING = '登入 HertzBeat'; +const USERNAME_PROMPT = '请输入用户名'; +const PASSWORD_PROMPT = '请输入密码'; +const PASSWORD_TOGGLE_LABEL = '显示密码'; +const REMEMBER_ME_LABEL = '记住我'; +const LOGIN_BUTTON = '登录'; vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn() - }), - useSearchParams: () => ({ - get: () => null }) })); vi.mock('next/image', () => ({ - default: ({ alt, src, priority: _priority, ...props }: any) => <img alt={alt} src={src} {...props} /> + default: ({ alt, src, priority: _priority, ...props }: any) => React.createElement('img', { alt, src, ...props }) })); vi.mock('../providers/i18n-provider', () => ({ useI18n: () => ({ - t: createTranslatorMock(), + t: createTranslatorMock({ locale: 'zh-CN' }), locale: 'zh-CN', locales: [ { code: 'en-US', labelKey: 'settings.system-config.locale.en_US', abbr: '🇬🇧' }, @@ -55,10 +54,9 @@ vi.mock('../ui/input', () => ({ })); vi.mock('../../lib/passport-login/controller', () => ({ - assertLoginSuccess: vi.fn(), + assertSessionLoginSuccess: vi.fn(), buildLoginRequestBody: vi.fn(), bootstrapPostLoginSession: vi.fn(), - persistLoginTokens: vi.fn(), resolvePostLoginRedirectTarget: vi.fn(() => '/overview'), LOGIN_REDIRECT_QUERY_KEY: 'redirect' })); @@ -86,7 +84,7 @@ describe('LoginForm', () => { expect(html).toContain(HERO_LEAD); expect(html).toContain(HERO_FOCUS); expect(html).toContain(HERO_BODY); - expect(html).not.toContain('Open-source private-deployable enterprise operations observability platform'); + expect(html).not.toContain('Open-source enterprise observability for private operations'); expect(html).not.toContain('Unified metrics platform, agentless and supports web, db, os, mid, network etc.'); expect(html).not.toContain('Unified logs platform'); expect(html).not.toContain('seamlessly integrates'); @@ -95,11 +93,35 @@ describe('LoginForm', () => { expect(html).toContain(USERNAME_PROMPT); expect(html).toContain(PASSWORD_PROMPT); expect(html).toContain(LOGIN_BUTTON); - expect(html).toContain('placeholder="Please enter your username"'); - expect(html).toContain('placeholder="Please enter password"'); + expect(html).toContain(`placeholder="${USERNAME_PROMPT}"`); + expect(html).toContain(`placeholder="${PASSWORD_PROMPT}"`); + expect(html).toContain(`aria-label="${PASSWORD_TOGGLE_LABEL}"`); expect(html).toContain('data-passport-login-password-eye="true"'); expect(html).toContain('data-passport-login-panel="angular-gray-card"'); expect(html).toContain('data-passport-login-panel-align="angular-top"'); + expect(html).toContain('data-passport-login-submit-lifecycle-contract="angular-required-default-warning-session-bootstrap-redirect"'); + expect(html).toContain('data-passport-login-submit-lifecycle-owner="hertzbeat-ui-passport-login-action"'); + expect(html).toContain('data-passport-login-required-mode-contract="angular-required-no-trim"'); + expect(html).toContain('data-passport-login-required-mode-owner="hertzbeat-ui-passport-login-action"'); + expect(html).toContain('data-passport-login-session-user-name-contract="angular-raw-identifier"'); + expect(html).toContain('data-passport-login-session-user-name-owner="hertzbeat-ui-passport-login-action"'); + expect(html).toContain('data-hz-ui="passport-login-action-frame"'); + expect(html).toContain('data-hz-passport-login-action-owner="hertzbeat-ui-passport-login-action"'); + expect(html).toContain('data-hz-passport-login-submit-lifecycle="angular-required-default-warning-session-bootstrap-redirect"'); + expect(html).toContain('data-hz-passport-login-required-fields="identifier-credential"'); + expect(html).toContain('data-hz-passport-login-required-mode="angular-required-no-trim"'); + expect(html).toContain('data-hz-passport-login-default-password="angular-first-submit-warning"'); + expect(html).toContain('data-hz-passport-login-default-password-lifecycle="angular-sticky-until-submit"'); + expect(html).toContain('data-passport-login-default-password-lifecycle-contract="angular-sticky-until-submit"'); + expect(html).toContain('data-hz-passport-login-token-boundary="bff-cookie-no-localstorage"'); + expect(html).toContain('data-hz-passport-login-session-bootstrap="angular-startup-load-after-success"'); + expect(html).toContain('data-hz-passport-login-session-user-name="angular-raw-identifier"'); + expect(html).toContain('data-hz-passport-login-startup-failure="angular-exception-500"'); + expect(html).toContain('data-passport-login-startup-failure-contract="angular-exception-500"'); + expect(html).toContain('data-hz-passport-login-redirect="angular-referrer-non-passport-fallback"'); + expect(html).toContain('data-hz-passport-login-redirect-fallback="angular-root-fallback"'); + expect(html).toContain('data-passport-login-redirect-fallback-contract="angular-root-fallback"'); + expect(html).toContain('data-hz-passport-login-remember-default="true"'); expect(html).toContain('data-passport-login-accent="true"'); expect(html).toContain('data-passport-login-remember="true"'); expect(html).toContain('data-passport-login-remember-checkbox="cold-checkbox"'); @@ -113,21 +135,23 @@ describe('LoginForm', () => { expect(html).toContain('data-passport-hero-offset="angular-left-reference"'); expect(html).toContain('data-passport-brand-lockup="angular-lowered"'); expect(html).toContain('data-passport-intro-bullet-tone="angular-cyan"'); - expect(html).toContain('Remember me'); + expect(html).toContain(REMEMBER_ME_LABEL); expect(html).toContain('data-passport-locale-trigger="globe"'); expect(html).toContain('data-passport-footer-tone="angular-muted"'); expect(html).toContain('data-passport-footer-band="angular-raised"'); expect(html).not.toContain(ZH_CN_LABEL); expect(html).toContain('Apache HertzBeat™'); expect(html).toContain('Apache HertzBeat™ v1.8.0'); - expect(html).toContain('Licensed under the Apache License, Version 2.0'); + expect(html).toContain('Copyright ©'); + expect(html).not.toContain('遵循 Apache License, Version 2.0 授权'); expect(html).not.toContain(LOGIN_NOTICE_COPY); expect(html).not.toContain('value="admin"'); expect(html).not.toContain('value="hertzbeat"'); - }, 15000); + }, 60000); it('removes the remaining bright auth-field residue and adopts shared ops form tokens', () => { const source = readFileSync(resolve(process.cwd(), 'components/pages/login-form.tsx'), 'utf8'); + const uiSource = readFileSync(resolve(process.cwd(), 'packages/hertzbeat-ui/src/index.tsx'), 'utf8'); expect(source).not.toContain('text-[#424955]'); expect(source).not.toContain('text-[#697180]'); @@ -142,17 +166,27 @@ describe('LoginForm', () => { expect(source).not.toContain('text-[#8f1d37]'); expect(source).not.toContain('pt-8 lg:pt-7'); - expect(source).toContain('text-[var(--ops-text-secondary)]'); + expect(uiSource).toContain('text-[var(--ops-text-secondary)]'); expect(source).toContain('text-[var(--ops-text-tertiary)]'); expect(source).toContain('border-[var(--ops-border-color)]'); expect(source).toContain('bg-[var(--ops-surface-panel)]'); - expect(source).toContain('bg-[var(--ops-surface-elevated)]'); - expect(source).toContain("from '../workbench/primitives'"); + expect(uiSource).toContain('bg-[var(--ops-surface-elevated)]'); + expect(source).toContain("import { HzPassportLoginActionFrame, HzPassportLoginNotice, HzPassportLoginValidationNotice } from '@hertzbeat/ui';"); + expect(source).toContain('<HzPassportLoginActionFrame'); expect(source).toContain("import { Checkbox } from '../ui/checkbox';"); - expect(source).toContain('StatusState'); + expect(source).toContain('<HzPassportLoginNotice copy={notice.copy} href={notice.href} />'); + expect(source).toContain('<HzPassportLoginValidationNotice title={t(\'common.attention\')} copy={error} />'); + expect(source).toContain('validateCredentialLoginDraft(identifier, credential, t)'); + expect(source).toContain("import { resetWorkbenchLoadCache } from '../../lib/workbench-load-cache';"); + expect(source).toContain('resetWorkbenchLoadCache();'); + expect(source).toContain('await bootstrapPostLoginSession(apiGet);'); expect(source).toContain('data-passport-login-panel-align="angular-top"'); + expect(source).not.toContain('<AlertTriangle'); expect(source).not.toContain('input type="checkbox"'); expect(source).not.toContain('<input type="checkbox" className="sr-only" defaultChecked />'); + expect(source).not.toContain('StatusState'); expect(source).not.toContain('ObservabilityStatusState'); + expect(source).toContain("aria-label={showCredential ? t('app.login.password-hide') : t('app.login.password-show')}"); + expect(source).toContain("label={t('app.login.remember-me')}"); }); }); diff --git a/web-next/components/pages/login-form.tsx b/web-next/components/pages/login-form.tsx index b8050f8d1c..60b1655582 100644 --- a/web-next/components/pages/login-form.tsx +++ b/web-next/components/pages/login-form.tsx @@ -2,23 +2,37 @@ import React from 'react'; import { FormEvent, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { AlertTriangle, Eye, EyeOff, LockKeyhole, UserRound } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Eye, EyeOff, LockKeyhole, UserRound } from 'lucide-react'; +import { HzPassportLoginActionFrame, HzPassportLoginNotice, HzPassportLoginValidationNotice } from '@hertzbeat/ui'; import { useI18n } from '../providers/i18n-provider'; import { Button } from '../ui/button'; import { Checkbox } from '../ui/checkbox'; import { Input } from '../ui/input'; -import { StatusState } from '../workbench/primitives'; import { apiGet } from '../../lib/api-client'; -import { assertLoginSuccess, bootstrapPostLoginSession, buildLoginRequestBody, LOGIN_REDIRECT_QUERY_KEY, persistLoginTokens, resolvePostLoginRedirectTarget } from '../../lib/passport-login/controller'; -import type { LoginMessage } from '../../lib/passport-login/controller'; -import { buildLoginNotice, shouldBlockDefaultPasswordSubmit, shouldWarnDefaultPassword } from '../../lib/passport-login/view-model'; +import { + assertSessionLoginSuccess, + bootstrapPostLoginSession, + buildLoginRequestBody, + buildPostLoginSessionUser, + resolvePostLoginStartupFailureTarget, + type LoginMessage, + type PassportLoginRouteState +} from '../../lib/passport-login/controller'; +import { buildLoginNotice, shouldBlockDefaultPasswordSubmit, shouldWarnDefaultPassword, validateCredentialLoginDraft } from '../../lib/passport-login/view-model'; +import { writeClientSessionUserSnapshot } from '../../lib/session-client'; +import { markAboutAutoShowAfterLogin } from '../../lib/shell/about'; +import { resetWorkbenchLoadCache } from '../../lib/workbench-load-cache'; import { PassportPanel, PassportShell } from './passport-shell'; -export function LoginForm() { +const EMPTY_PASSPORT_LOGIN_ROUTE_STATE: PassportLoginRouteState = { + redirectTarget: '/' +}; + +export function LoginForm({ initialRouteState }: { initialRouteState?: PassportLoginRouteState } = {}) { const { t } = useI18n(); const router = useRouter(); - const searchParams = useSearchParams(); + const passportLoginRouteState = initialRouteState ?? EMPTY_PASSPORT_LOGIN_ROUTE_STATE; const [identifier, setIdentifier] = useState(''); const [credential, setCredential] = useState(''); const [showCredential, setShowCredential] = useState(false); @@ -32,13 +46,21 @@ export function LoginForm() { async function handleSubmit(event: FormEvent) { event.preventDefault(); + const validation = validateCredentialLoginDraft(identifier, credential, t); + if (validation) { + setError(validation.message); + return; + } + if (shouldBlockDefaultPasswordSubmit(needUpdatePassword, credential)) { setNeedUpdatePassword(true); setError(null); return; } - setNeedUpdatePassword(shouldWarnDefaultPassword(credential)); + if (shouldWarnDefaultPassword(credential)) { + setNeedUpdatePassword(true); + } setLoading(true); setError(null); try { @@ -48,14 +70,21 @@ export function LoginForm() { body: JSON.stringify(buildLoginRequestBody(identifier, credential)) }); const message = (await response.json()) as LoginMessage; - const tokens = assertLoginSuccess( + assertSessionLoginSuccess( response.status, message, t('passport.login.error.with-status') ); - persistLoginTokens(window.localStorage, tokens); - await bootstrapPostLoginSession(apiGet); - router.replace(resolvePostLoginRedirectTarget(searchParams.get(LOGIN_REDIRECT_QUERY_KEY))); + resetWorkbenchLoadCache(); + writeClientSessionUserSnapshot(buildPostLoginSessionUser(identifier, message)); + try { + await bootstrapPostLoginSession(apiGet); + } catch { + router.replace(resolvePostLoginStartupFailureTarget()); + return; + } + markAboutAutoShowAfterLogin(); + router.replace(passportLoginRouteState.redirectTarget); } catch (err) { setError(err instanceof Error ? err.message : t('passport.login.error.generic')); } finally { @@ -68,6 +97,11 @@ export function LoginForm() { <div data-passport-login-panel="angular-gray-card" data-passport-login-panel-align="angular-top" + data-passport-login-submit-lifecycle-contract="angular-required-default-warning-session-bootstrap-redirect" + data-passport-login-default-password-lifecycle-contract="angular-sticky-until-submit" + data-passport-login-startup-failure-contract="angular-exception-500" + data-passport-login-redirect-fallback-contract="angular-root-fallback" + data-passport-login-submit-lifecycle-owner="hertzbeat-ui-passport-login-action" > <PassportPanel className="rounded-none border border-[rgba(33,35,42,0.68)] border-b-[var(--ops-primary)] border-r-[var(--ops-primary)] bg-[#5f5f66] px-5 py-9 shadow-none" @@ -83,81 +117,88 @@ export function LoginForm() { </div> )} > - <form onSubmit={handleSubmit} className="mt-4 space-y-4"> - <label className="block space-y-2"> - <span className={fieldLabelClassName}> - {t('app.login.message-need-identifier')} - </span> - <div className="relative"> - <UserRound size={16} className={fieldIconClassName} /> - <Input - value={identifier} - onChange={e => setIdentifier(e.target.value)} - placeholder={t('app.login.message-need-identifier')} - className={fieldInputClassName} - /> - </div> - </label> - <label className="block space-y-2"> - <span className={fieldLabelClassName}> - {t('app.login.message-need-credential')} - </span> - <div className="relative"> - <LockKeyhole size={16} className={fieldIconClassName} /> - <Input - value={credential} - onChange={e => { - const nextValue = e.target.value; - setCredential(nextValue); - if (!shouldWarnDefaultPassword(nextValue) && needUpdatePassword) { - setNeedUpdatePassword(false); - } - }} - placeholder={t('app.login.message-need-credential')} - type={showCredential ? 'text' : 'password'} - className={`${fieldInputClassName} pr-10`} + <HzPassportLoginActionFrame + className="mt-4" + data-passport-login-submit-lifecycle="angular-required-default-warning-session-bootstrap-redirect" + data-passport-login-submit-lifecycle-owner="hertzbeat-ui-passport-login-action" + data-passport-login-required-mode-contract="angular-required-no-trim" + data-passport-login-required-mode-owner="hertzbeat-ui-passport-login-action" + data-passport-login-session-user-name-contract="angular-raw-identifier" + data-passport-login-session-user-name-owner="hertzbeat-ui-passport-login-action" + > + <form onSubmit={handleSubmit} className="space-y-4"> + <label className="block space-y-2"> + <span className={fieldLabelClassName}> + {t('app.login.message-need-identifier')} + </span> + <div className="relative"> + <UserRound size={16} className={fieldIconClassName} /> + <Input + value={identifier} + onChange={e => { + setIdentifier(e.target.value); + if (error) { + setError(null); + } + }} + placeholder={t('app.login.message-need-identifier')} + className={fieldInputClassName} + /> + </div> + </label> + <label className="block space-y-2"> + <span className={fieldLabelClassName}> + {t('app.login.message-need-credential')} + </span> + <div className="relative"> + <LockKeyhole size={16} className={fieldIconClassName} /> + <Input + value={credential} + onChange={e => { + const nextValue = e.target.value; + setCredential(nextValue); + if (error) { + setError(null); + } + }} + placeholder={t('app.login.message-need-credential')} + type={showCredential ? 'text' : 'password'} + className={`${fieldInputClassName} pr-10`} + /> + <button + aria-label={showCredential ? t('app.login.password-hide') : t('app.login.password-show')} + className="absolute right-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center text-[var(--ops-text-tertiary)] transition-colors hover:text-white" + data-passport-login-password-eye="true" + type="button" + onClick={() => setShowCredential(current => !current)} + > + {showCredential ? <EyeOff size={16} aria-hidden="true" /> : <Eye size={16} aria-hidden="true" />} + </button> + </div> + </label> + + <div data-passport-login-remember="true"> + <Checkbox + data-passport-login-remember-checkbox="cold-checkbox" + defaultChecked + containerClassName="min-h-4 gap-2 text-[13px] font-medium text-[#17181c]" + label={t('app.login.remember-me')} /> - <button - aria-label={showCredential ? 'Hide password' : 'Show password'} - className="absolute right-3 top-1/2 inline-flex -translate-y-1/2 items-center justify-center text-[var(--ops-text-tertiary)] transition-colors hover:text-white" - data-passport-login-password-eye="true" - type="button" - onClick={() => setShowCredential(current => !current)} - > - {showCredential ? <EyeOff size={16} aria-hidden="true" /> : <Eye size={16} aria-hidden="true" />} - </button> </div> - </label> - - <div data-passport-login-remember="true"> - <Checkbox - data-passport-login-remember-checkbox="cold-checkbox" - defaultChecked - containerClassName="min-h-4 gap-2 text-[13px] font-medium text-[#17181c]" - label="Remember me" - /> - </div> - {notice.kind === 'warning' ? ( - <a - className="flex items-start gap-3 rounded-[6px] border border-[rgba(216,111,91,0.28)] bg-[var(--ops-surface-elevated)] px-4 py-3 text-sm text-[var(--ops-text-secondary)] transition-colors hover:border-[var(--ops-primary)] hover:text-[var(--ops-text-primary)]" - href={notice.href} - target="_blank" - rel="noreferrer" - > - <AlertTriangle size={16} className="mt-0.5 shrink-0 text-[var(--ops-primary)]" /> - <span>{notice.copy}</span> - </a> - ) : null} + {notice.kind === 'warning' ? ( + <HzPassportLoginNotice copy={notice.copy} href={notice.href} /> + ) : null} - <Button className="h-11 w-full" variant="primary" type="submit" disabled={loading} size="lg"> - {loading ? t('passport.login.loading') : t('app.login.login')} - </Button> - </form> + <Button className="h-11 w-full" variant="primary" type="submit" disabled={loading} size="lg"> + {loading ? t('passport.login.loading') : t('app.login.login')} + </Button> + </form> + </HzPassportLoginActionFrame> {error ? ( <div className="mt-4"> - <StatusState title={t('common.attention')} copy={error} tone="danger" /> + <HzPassportLoginValidationNotice title={t('common.attention')} copy={error} /> </div> ) : null} </PassportPanel> diff --git a/web-next/components/pages/passport-shell-session-clear.test.tsx b/web-next/components/pages/passport-shell-session-clear.test.tsx new file mode 100644 index 0000000000..78394261fd --- /dev/null +++ b/web-next/components/pages/passport-shell-session-clear.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HB_UI_SESSION_USER_KEY } from '../../lib/session-client'; +import { createTranslatorMock } from '../../test/i18n-test-helper'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('next/image', () => ({ + default: ({ alt, src, priority: _priority, ...props }: any) => React.createElement('img', { alt, src, ...props }) +})); + +vi.mock('../providers/i18n-provider', () => ({ + useI18n: () => ({ + t: createTranslatorMock({ locale: 'zh-CN' }), + locale: 'zh-CN', + locales: [ + { code: 'en-US', labelKey: 'settings.system-config.locale.en_US', abbr: 'gb' }, + { code: 'zh-CN', labelKey: 'settings.system-config.locale.zh_CN', abbr: 'cn' } + ], + setLocale: vi.fn() + }) +})); + +vi.mock('../shell/locale-option-list', () => ({ + LocaleOptionList: () => <div data-locale-options="true">locale-options</div> +})); + +vi.mock('../shell/platform-copyright-footer', () => ({ + PlatformCopyrightFooter: ({ + children, + headlineClassName: _headlineClassName, + innerClassName: _innerClassName, + lineClassName: _lineClassName, + linkClassName: _linkClassName, + version: _version, + ...props + }: any) => <footer {...props}>{children}</footer> +})); + +describe('PassportShell session clear lifecycle', () => { + let container: HTMLDivElement; + let root: Root; + + async function mountShell() { + const { PassportShell } = await import('./passport-shell'); + await act(async () => { + root.render( + <PassportShell> + <div data-passport-child="true">child</div> + </PassportShell> + ); + await Promise.resolve(); + }); + } + + async function mountLockShell() { + const { PassportShell } = await import('./passport-shell'); + await act(async () => { + root.render( + <PassportShell sessionLifecycle="preserve-on-lock"> + <div data-passport-child="true">child</div> + </PassportShell> + ); + await Promise.resolve(); + }); + } + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + window.sessionStorage.clear(); + document.cookie = 'hb_ui_session=1; path=/'; + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + window.sessionStorage.clear(); + vi.unstubAllGlobals(); + }); + + it('clears Angular-style client session residue on passport entry without calling the logout API', async () => { + window.sessionStorage.setItem( + HB_UI_SESSION_USER_KEY, + JSON.stringify({ name: 'admin', avatar: './assets/img/avatar.svg', email: 'administrator', role: 'ADMIN' }) + ); + + await mountShell(); + + expect(container.querySelector('[data-hz-ui="passport-session-clear-frame"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-session-clear-contract="angular-token-service-clear-on-passport-entry"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-session-clear-owner="hertzbeat-ui-passport-session-clear"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-session-clear-lifecycle="angular-token-service-clear-on-passport-entry"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-session-clear-scope="client-marker-user-snapshot"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-session-clear-boundary="no-api-logout-on-entry"]')).not.toBeNull(); + expect(window.sessionStorage.getItem(HB_UI_SESSION_USER_KEY)).toBeNull(); + expect(document.cookie).not.toContain('hb_ui_session=1'); + expect(global.fetch).not.toHaveBeenCalled(); + }, 15000); + + it('preserves client session residue on the Angular lock entry', async () => { + window.sessionStorage.setItem( + HB_UI_SESSION_USER_KEY, + JSON.stringify({ name: 'admin', avatar: './assets/img/avatar.svg', email: 'administrator', role: 'ADMIN' }) + ); + + await mountLockShell(); + + expect(container.querySelector('[data-passport-session-clear-contract="angular-lock-preserve-session"]')).not.toBeNull(); + expect(container.querySelector('[data-passport-session-clear-enabled="false"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-session-clear-lifecycle="angular-lock-preserve-session"]')).not.toBeNull(); + expect(container.querySelector('[data-hz-passport-session-clear-scope="client-marker-user-snapshot-preserved"]')).not.toBeNull(); + expect(window.sessionStorage.getItem(HB_UI_SESSION_USER_KEY)).toContain('"name":"admin"'); + expect(document.cookie).toContain('hb_ui_session=1'); + expect(global.fetch).not.toHaveBeenCalled(); + }, 15000); +}); diff --git a/web-next/components/pages/passport-shell.test.tsx b/web-next/components/pages/passport-shell.test.tsx index 42cb958e61..0ef944d5f0 100644 --- a/web-next/components/pages/passport-shell.test.tsx +++ b/web-next/components/pages/passport-shell.test.tsx @@ -7,12 +7,12 @@ import { PassportPanel, PassportShell } from './passport-shell'; import { createTranslatorMock } from '../../test/i18n-test-helper'; vi.mock('next/image', () => ({ - default: ({ alt, src, priority: _priority, ...props }: any) => <img alt={alt} src={src} {...props} /> + default: ({ alt, src, priority: _priority, ...props }: any) => React.createElement('img', { alt, src, ...props }) })); vi.mock('../providers/i18n-provider', () => ({ useI18n: () => ({ - t: createTranslatorMock(), + t: createTranslatorMock({ locale: 'zh-CN' }), locale: 'zh-CN', locales: [ { code: 'en-US', labelKey: 'settings.system-config.locale.en_US', abbr: '🇬🇧' }, @@ -39,7 +39,7 @@ vi.mock('../shell/platform-copyright-footer', () => ({ <footer {...props}> <div className={headlineClassName}>Apache HertzBeat™{version ? ` ${version}` : ''}</div> <a className={linkClassName}>Apache HertzBeat™</a> - <div className={lineClassName}>{children ?? 'Licensed under the Apache License, Version 2.0'}</div> + {children ? <div className={lineClassName}>{children}</div> : null} </footer> ) })); @@ -57,6 +57,13 @@ describe('PassportShell', () => { ); expect(html).toContain('data-passport-shell="true"'); + expect(html).toContain('data-hz-ui="passport-session-clear-frame"'); + expect(html).toContain('data-passport-session-clear-contract="angular-token-service-clear-on-passport-entry"'); + expect(html).toContain('data-passport-session-clear-enabled="true"'); + expect(html).toContain('data-passport-session-clear-owner="hertzbeat-ui-passport-session-clear"'); + expect(html).toContain('data-hz-passport-session-clear-lifecycle="angular-token-service-clear-on-passport-entry"'); + expect(html).toContain('data-hz-passport-session-clear-scope="client-marker-user-snapshot"'); + expect(html).toContain('data-hz-passport-session-clear-boundary="no-api-logout-on-entry"'); expect(html).toContain('data-passport-shell-spacing="angular-reference"'); expect(html).toContain('data-passport-brand-lockup="angular-lowered"'); expect(html).toContain('data-passport-content-alignment="angular-centered"'); @@ -76,7 +83,32 @@ describe('PassportShell', () => { expect(html).toContain('data-passport-footer-band="angular-raised"'); expect(html).toContain('/assets/bg.png'); expect(html).toContain('Apache HertzBeat™ v1.8.0'); - expect(html).toContain('Licensed under the Apache License, Version 2.0'); + expect(html).toContain('开源私有化企业运维可观测平台'); + expect(html).toContain('采集器、监控模板、实体、指标、日志和链路'); + expect(html).toContain('处理告警并关闭问题'); + expect(html).toContain('通过采集器接入应用、数据库、操作系统、中间件和网络设备指标,数据留在私有化部署内。'); + expect(html).toContain('按网络区域扩展 Collector 集群,支撑私有化、隔离采集和状态页。'); + expect(html).not.toContain('遵循 Apache License, Version 2.0 授权'); + expect(html).not.toContain('Apache License, Version 2.0'); + }); + + it('can preserve the active session for the Angular lock route', () => { + const html = renderToStaticMarkup( + <PassportShell panelClassName="max-w-[712px]" sessionLifecycle="preserve-on-lock"> + <PassportPanel title="Unlock"> + <form data-passport-lock-form="true"> + <input /> + </form> + </PassportPanel> + </PassportShell> + ); + + expect(html).toContain('data-passport-session-clear-contract="angular-lock-preserve-session"'); + expect(html).toContain('data-passport-session-clear-enabled="false"'); + expect(html).toContain('data-hz-passport-session-clear-lifecycle="angular-lock-preserve-session"'); + expect(html).toContain('data-hz-passport-session-clear-scope="client-marker-user-snapshot-preserved"'); + expect(html).toContain('data-hz-passport-session-clear-boundary="no-session-clear-on-lock"'); + expect(html).toContain('data-passport-lock-form="true"'); }); it('removes the remaining bright auth shell residue and adopts ops tokens', () => { @@ -104,9 +136,16 @@ describe('PassportShell', () => { expect(source).toContain('text-[var(--ops-text-secondary)]'); expect(source).toContain('text-[var(--ops-primary)]'); expect(source).toContain("from '../workbench/primitives'"); + expect(source).toContain("import { HzPassportSessionClearFrame } from '@hertzbeat/ui';"); + expect(source).toContain("import { clearClientSessionMarker, clearClientSessionUserSnapshot } from '../../lib/session-client';"); + expect(source).toContain('clearClientSessionMarker();'); + expect(source).toContain('clearClientSessionUserSnapshot();'); + expect(source).not.toContain('clearClientSession();'); expect(source).toContain('WorkbenchPanel'); expect(source).toContain('data-passport-locale-trigger="globe"'); expect(source).toContain('data-passport-locale-tone="angular-magenta"'); + expect(source).toContain("aria-label={t('app.passport.language-switch')}"); + expect(source).not.toContain('aria-label="Switch language"'); expect(source).toContain('text-[#d11ce6]'); expect(source).toContain('data-passport-background-overlay="angular-light"'); expect(source).toContain('data-passport-intro-list="angular-single-column"'); diff --git a/web-next/components/pages/passport-shell.tsx b/web-next/components/pages/passport-shell.tsx index 325dc16bd8..80a12be448 100644 --- a/web-next/components/pages/passport-shell.tsx +++ b/web-next/components/pages/passport-shell.tsx @@ -1,17 +1,20 @@ 'use client'; -import React, { type ReactNode, useState } from 'react'; +import React, { type ReactNode, useEffect, useState } from 'react'; import Image from 'next/image'; import { Globe2 } from 'lucide-react'; +import { HzPassportSessionClearFrame } from '@hertzbeat/ui'; import { useI18n } from '../providers/i18n-provider'; import { LocaleOptionList } from '../shell/locale-option-list'; import { PlatformCopyrightFooter } from '../shell/platform-copyright-footer'; import { WorkbenchPanel } from '../workbench/primitives'; +import { clearClientSessionMarker, clearClientSessionUserSnapshot } from '../../lib/session-client'; import { cn } from '../../lib/utils'; type PassportShellProps = { children: ReactNode; panelClassName?: string; + sessionLifecycle?: 'clear-on-entry' | 'preserve-on-lock'; }; type PassportPanelProps = { @@ -21,9 +24,11 @@ type PassportPanelProps = { className?: string; }; -export function PassportShell({ children, panelClassName }: PassportShellProps) { +export function PassportShell({ children, panelClassName, sessionLifecycle = 'clear-on-entry' }: PassportShellProps) { const { t, locale, locales, setLocale } = useI18n(); const [localeOpen, setLocaleOpen] = useState(false); + const shouldClearSession = sessionLifecycle === 'clear-on-entry'; + const sessionContract = shouldClearSession ? 'angular-token-service-clear-on-passport-entry' : 'angular-lock-preserve-session'; const passportPoints = [ t('about.point.1'), t('about.point.2'), @@ -33,11 +38,23 @@ export function PassportShell({ children, panelClassName }: PassportShellProps) t('about.point.6') ]; + useEffect(() => { + if (!shouldClearSession) { + return; + } + clearClientSessionMarker(); + clearClientSessionUserSnapshot(); + }, [shouldClearSession]); + return ( - <div + <HzPassportSessionClearFrame className="relative min-h-screen overflow-hidden bg-[var(--ops-background)] bg-cover bg-center bg-no-repeat text-[var(--ops-text-primary)]" + lifecycle={sessionLifecycle} data-login-shell="passport" data-passport-shell="true" + data-passport-session-clear-contract={sessionContract} + data-passport-session-clear-enabled={shouldClearSession ? 'true' : 'false'} + data-passport-session-clear-owner="hertzbeat-ui-passport-session-clear" style={{ backgroundImage: "url('/assets/bg.png')" }} > <div @@ -53,7 +70,7 @@ export function PassportShell({ children, panelClassName }: PassportShellProps) <div className="absolute right-0 top-0 hidden md:block"> <div className="relative"> <button - aria-label="Switch language" + aria-label={t('app.passport.language-switch')} className="inline-flex h-9 w-9 items-center justify-center text-[#d11ce6] transition-colors hover:text-[#f149ff]" data-passport-locale-trigger="globe" data-passport-locale-tone="angular-magenta" @@ -149,7 +166,7 @@ export function PassportShell({ children, panelClassName }: PassportShellProps) /> </div> </div> - </div> + </HzPassportSessionClearFrame> ); } diff --git a/web-next/lib/passport-lock/view-model.test.ts b/web-next/lib/passport-lock/view-model.test.ts index a5a34bed59..9b650e5858 100644 --- a/web-next/lib/passport-lock/view-model.test.ts +++ b/web-next/lib/passport-lock/view-model.test.ts @@ -8,14 +8,14 @@ describe('passport lock view model', () => { it('builds lock page facts', () => { expect(buildLockFacts(t)).toEqual([ { label: '工作区', value: 'passport/lock' }, - { label: '状态', value: 'interactive' }, - { label: '下一步', value: 'wire unlock validation if needed' } + { label: '状态', value: '可解锁' }, + { label: '下一步', value: '按需接入解锁校验' } ]); }); it('requires a non-empty password', () => { expect(validateUnlockPassword('', t)).toBe('请输入密码'); - expect(validateUnlockPassword(' ', t)).toBe('请输入密码'); + expect(validateUnlockPassword(' ', t)).toBeNull(); expect(validateUnlockPassword('secret', t)).toBeNull(); }); }); diff --git a/web-next/lib/passport-lock/view-model.ts b/web-next/lib/passport-lock/view-model.ts index 72e5114a7d..6d692170da 100644 --- a/web-next/lib/passport-lock/view-model.ts +++ b/web-next/lib/passport-lock/view-model.ts @@ -3,13 +3,13 @@ type Translator = (key: string, params?: Record<string, string | number | null | export function buildLockFacts(t: Translator) { return [ { label: t('common.workspace'), value: 'passport/lock' }, - { label: t('common.status'), value: 'interactive' }, + { label: t('common.status'), value: t('passport.lock.status.interactive') }, { label: t('common.next-step'), value: t('passport.lock.next-step') } ]; } export function validateUnlockPassword(password: string, t: Translator) { - if (!password.trim()) { + if (password.length === 0) { return t('passport.lock.error.required'); } return null; diff --git a/web-next/lib/passport-login/controller.test.ts b/web-next/lib/passport-login/controller.test.ts index 73759e5a7a..5d04bc41e3 100644 --- a/web-next/lib/passport-login/controller.test.ts +++ b/web-next/lib/passport-login/controller.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it, vi } from 'vitest'; import { assertLoginSuccess, + assertSessionLoginSuccess, bootstrapPostLoginSession, + buildLoginCompatRouteUrl, buildLoginRedirectHref, buildLoginRequestBody, buildLoginReturnTo, - persistLoginTokens, + buildPostLoginSessionUser, + readPassportLoginRouteState, resolveLoginError, resolvePostLoginRedirectTarget, + resolvePostLoginStartupFailureTarget, sanitizeLoginRedirectTarget } from './controller'; @@ -36,13 +40,43 @@ describe('passport login controller', () => { expect(() => assertLoginSuccess(200, { code: 0, data: { token: 'access' } }, 'Login failed: {{status}}')).toThrow('Login failed: 200'); }); - it('persists access and refresh tokens', () => { - const storage = { setItem: vi.fn() }; + it('accepts BFF-cookie login success without exposing tokens to the browser', () => { + expect( + assertSessionLoginSuccess(200, { code: 0, data: { authenticated: true } }, 'Login failed: {{status}}') + ).toBeUndefined(); + + expect(() => assertSessionLoginSuccess(401, { code: 1 }, 'Login failed: {{status}}')).toThrow('Login failed: 401'); + expect(() => assertSessionLoginSuccess(200, { code: 1, msg: 'denied' }, 'Login failed: {{status}}')).toThrow('denied'); + }); + + it('builds the Angular-style post-login user snapshot from the raw submitted identifier', () => { + expect( + buildPostLoginSessionUser(' ops-admin ', { + code: 0, + data: { + authenticated: true, + tokenBoundary: 'bff-cookie', + role: 'ADMIN' + } + }) + ).toEqual({ + name: ' ops-admin ', + avatar: './assets/img/avatar.svg', + email: 'administrator', + role: 'ADMIN' + }); - persistLoginTokens(storage, { token: 'access', refreshToken: 'refresh' }); + expect(buildPostLoginSessionUser(' ', { code: 0, data: { authenticated: true } })).toEqual({ + name: ' ', + avatar: './assets/img/avatar.svg', + email: 'administrator' + }); - expect(storage.setItem).toHaveBeenNthCalledWith(1, 'Authorization', 'access'); - expect(storage.setItem).toHaveBeenNthCalledWith(2, 'refresh-token', 'refresh'); + expect(buildPostLoginSessionUser('', { code: 0, data: { authenticated: true } })).toEqual({ + name: 'admin', + avatar: './assets/img/avatar.svg', + email: 'administrator' + }); }); it('sanitizes login redirect targets to internal non-auth routes', () => { @@ -87,20 +121,68 @@ describe('passport login controller', () => { expect(buildLoginRedirectHref('/passport/login')).toBe('/passport/login'); expect(buildLoginRedirectHref('/passport/login', '/login')).toBe('/login'); expect(resolvePostLoginRedirectTarget('/monitors?app=website')).toBe('/monitors?app=website'); - expect(resolvePostLoginRedirectTarget('/passport/login')).toBe('/overview'); - expect(resolvePostLoginRedirectTarget('https://example.com')).toBe('/overview'); + expect(resolvePostLoginRedirectTarget('/passport/login')).toBe('/'); + expect(resolvePostLoginRedirectTarget('https://example.com')).toBe('/'); + expect(resolvePostLoginRedirectTarget('/passport/login', '/overview')).toBe('/overview'); + expect(resolvePostLoginStartupFailureTarget()).toBe('/exception/500'); + }); + + it('builds the compatibility login alias URL while preserving guard context', () => { + expect(buildLoginCompatRouteUrl()).toBe('/passport/login'); + expect(buildLoginCompatRouteUrl({ redirect: '/monitors?app=website', source: 'guard' })).toBe( + '/passport/login?redirect=%2Fmonitors%3Fapp%3Dwebsite&source=guard' + ); + expect(buildLoginCompatRouteUrl({ redirect: '/trace/manage?traceId=1', returnLabel: 'Login' })).toBe( + '/passport/login?redirect=%2Ftrace%2Fmanage%3FtraceId%3D1' + ); + }); + + it('normalizes multi-value URL search params into the first passport login redirect target', () => { + expect( + readPassportLoginRouteState({ + redirect: ['/monitors?app=website', '/trace/manage?traceId=1'], + returnLabel: ['Login', 'Ignored'] + }) + ).toEqual({ + redirectTarget: '/monitors?app=website' + }); + + expect( + readPassportLoginRouteState({ + redirect: ['https://evil.example', '/monitors?app=website'] + }) + ).toEqual({ + redirectTarget: '/' + }); }); - it('warms the post-login session bootstrap without failing on bootstrap misses', async () => { + it('warms the post-login session bootstrap after login success', async () => { const apiGet = vi .fn() - .mockResolvedValueOnce({ locale: 'en-US' }); + .mockResolvedValueOnce({ code: 0, data: { locale: 'zh_CN' } }) + .mockResolvedValueOnce({ code: 0, data: [] }); await expect(bootstrapPostLoginSession(apiGet as any)).resolves.toBeUndefined(); - expect(apiGet).toHaveBeenCalledWith('/config/system'); + expect(apiGet).toHaveBeenNthCalledWith(1, '/config/system'); + expect(apiGet).toHaveBeenNthCalledWith(2, '/apps/hierarchy?lang=zh-CN'); - const failingApiGet = vi.fn().mockRejectedValueOnce(new Error('bootstrap failed')); + const failingApiGet = vi + .fn() + .mockRejectedValueOnce(new Error('bootstrap failed')) + .mockResolvedValueOnce({ code: 0, data: [] }); await expect(bootstrapPostLoginSession(failingApiGet as any)).resolves.toBeUndefined(); - expect(failingApiGet).toHaveBeenCalledWith('/config/system'); + expect(failingApiGet).toHaveBeenNthCalledWith(1, '/config/system'); + expect(failingApiGet).toHaveBeenNthCalledWith(2, '/apps/hierarchy?lang=en-US'); + }); + + it('preserves Angular startup failure routing when the hierarchy bootstrap fails', async () => { + const apiGet = vi + .fn() + .mockResolvedValueOnce({ code: 0, data: { locale: 'zh_CN' } }) + .mockRejectedValueOnce(new Error('hierarchy failed')); + + await expect(bootstrapPostLoginSession(apiGet as any)).rejects.toThrow('hierarchy failed'); + expect(apiGet).toHaveBeenNthCalledWith(1, '/config/system'); + expect(apiGet).toHaveBeenNthCalledWith(2, '/apps/hierarchy?lang=zh-CN'); }); }); diff --git a/web-next/lib/passport-login/controller.ts b/web-next/lib/passport-login/controller.ts index 6eca563773..d9682b901c 100644 --- a/web-next/lib/passport-login/controller.ts +++ b/web-next/lib/passport-login/controller.ts @@ -1,21 +1,42 @@ +import { buildCompatRedirectTarget, createCompatSearchParamReader, type SearchParamsRecord } from '../compat/search-params'; import { stripReturnLabelFromHref } from '../signal-route-context'; +export type { SearchParamsRecord } from '../compat/search-params'; + export type LoginTokens = { token: string; refreshToken: string; }; +export type PostLoginSessionUser = { + name: string; + avatar: string; + email: string; + role?: string; +}; + +export type PassportLoginSearchParams = SearchParamsRecord; + +export type PassportLoginRouteState = { + redirectTarget: string; +}; + export const DEFAULT_LOGIN_ENTRY_PATH = '/passport/login'; export const LOGIN_REDIRECT_QUERY_KEY = 'redirect'; +export const POST_LOGIN_STARTUP_FAILURE_PATH = '/exception/500'; + +export function buildLoginCompatRouteUrl(searchParams?: SearchParamsRecord) { + return buildCompatRedirectTarget(DEFAULT_LOGIN_ENTRY_PATH, searchParams); +} export type LoginMessage = { code?: number; msg?: string; - data?: Partial<LoginTokens>; -}; - -type StorageLike = { - setItem: (key: string, value: string) => void; + data?: Partial<LoginTokens> & { + authenticated?: boolean; + tokenBoundary?: string; + role?: unknown; + }; }; type LocationLike = { @@ -26,6 +47,13 @@ type LocationLike = { type ApiGetter = <T>(path: string) => Promise<T>; +type SystemConfigPayload = { + code?: number; + data?: { + locale?: unknown; + } | null; +}; + export function buildLoginRequestBody(identifier: string, credential: string) { return { type: 0, @@ -49,9 +77,24 @@ export function assertLoginSuccess(status: number, message: LoginMessage, fallba }; } -export function persistLoginTokens(storage: StorageLike, tokens: LoginTokens) { - storage.setItem('Authorization', tokens.token); - storage.setItem('refresh-token', tokens.refreshToken); +export function assertSessionLoginSuccess(status: number, message: LoginMessage, fallback: string) { + if (status >= 400 || message.code !== 0) { + throw new Error(resolveLoginError(status, message, fallback)); + } +} + +export function buildPostLoginSessionUser(identifier: string, message: LoginMessage): PostLoginSessionUser { + const name = identifier || 'admin'; + const role = typeof message.data?.role === 'string' && message.data.role.trim() + ? message.data.role.trim() + : undefined; + + return { + name, + avatar: './assets/img/avatar.svg', + email: 'administrator', + ...(role ? { role } : {}) + }; } export function sanitizeLoginRedirectTarget(value?: string | null) { @@ -91,12 +134,35 @@ export function buildLoginRedirectHref(returnTo?: string | null, loginPath?: str return `${resolvedLoginPath}?${params.toString()}`; } -export function resolvePostLoginRedirectTarget(returnTo?: string | null, fallback = '/overview') { +export function resolvePostLoginRedirectTarget(returnTo?: string | null, fallback = '/') { return sanitizeLoginRedirectTarget(returnTo) || fallback; } -export async function bootstrapPostLoginSession(apiGet: ApiGetter) { - await Promise.allSettled([ - apiGet('/config/system') - ]); +export function resolvePostLoginStartupFailureTarget() { + return POST_LOGIN_STARTUP_FAILURE_PATH; +} + +export function readPassportLoginRouteState(searchParams?: PassportLoginSearchParams): PassportLoginRouteState { + const reader = createCompatSearchParamReader(searchParams); + return { + redirectTarget: resolvePostLoginRedirectTarget(reader.get(LOGIN_REDIRECT_QUERY_KEY)) + }; +} + +function normalizeStartupLocale(locale: unknown, fallback = 'en-US') { + if (typeof locale !== 'string') return fallback; + const nextLocale = locale.trim().replace('_', '-'); + return nextLocale || fallback; +} + +export async function bootstrapPostLoginSession(apiGet: ApiGetter, fallbackLocale = 'en-US') { + let configPayload: SystemConfigPayload | null = null; + try { + configPayload = await apiGet<SystemConfigPayload>('/config/system'); + } catch { + configPayload = null; + } + const startupLocale = normalizeStartupLocale(configPayload?.data?.locale, fallbackLocale); + + await apiGet(`/apps/hierarchy?lang=${encodeURIComponent(startupLocale)}`); } diff --git a/web-next/lib/passport-login/view-model.test.ts b/web-next/lib/passport-login/view-model.test.ts index de2f1c985b..8a8ee1c445 100644 --- a/web-next/lib/passport-login/view-model.test.ts +++ b/web-next/lib/passport-login/view-model.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildLoginFeatureCards, buildLoginNotice, shouldBlockDefaultPasswordSubmit, shouldWarnDefaultPassword } from './view-model'; +import { buildLoginFeatureCards, buildLoginNotice, shouldBlockDefaultPasswordSubmit, shouldWarnDefaultPassword, validateCredentialLoginDraft } from './view-model'; import { createTranslatorMock } from '../../test/i18n-test-helper'; const t = createTranslatorMock({ locale: 'zh-CN' }); +const enT = createTranslatorMock({ locale: 'en-US' }); describe('passport login view model', () => { it('detects the default password warning condition', () => { @@ -16,6 +17,20 @@ describe('passport login view model', () => { expect(shouldBlockDefaultPasswordSubmit(false, 'custom')).toBe(false); }); + it('validates the Angular credential-login required fields before submit', () => { + expect(validateCredentialLoginDraft('', 'custom', enT)).toEqual({ + field: 'identifier', + message: 'Please enter your username' + }); + expect(validateCredentialLoginDraft('ops-admin', '', enT)).toEqual({ + field: 'credential', + message: 'Please enter password' + }); + expect(validateCredentialLoginDraft(' ops-admin ', ' custom ', enT)).toBeNull(); + expect(validateCredentialLoginDraft(' ', 'custom', enT)).toBeNull(); + expect(validateCredentialLoginDraft('ops-admin', ' ', enT)).toBeNull(); + }); + it('builds the login feature cards', () => { expect(buildLoginFeatureCards(t)).toEqual([ { title: '运维入口', copy: '登录后继续查看资源、实体、遥测数据和告警。' }, @@ -27,7 +42,7 @@ describe('passport login view model', () => { it('builds the warning or session notice', () => { expect(buildLoginNotice(true, t)).toEqual({ kind: 'warning', - copy: '当前使用默认密码,建议登录后尽快修改。', + copy: '请及时更新初始默认密码!', href: 'https://hertzbeat.apache.org/docs/start/account-modify' }); @@ -36,4 +51,14 @@ describe('passport login view model', () => { copy: '登录成功后会自动恢复当前工作台会话,并在需要时尝试刷新令牌。' }); }); + + it('builds the English session notice without localized fallback copy', () => { + const notice = buildLoginNotice(false, enT); + + expect(notice).toEqual({ + kind: 'session', + copy: 'After login, HertzBeat will restore the current workspace session and try to refresh tokens when needed.' + }); + expect(notice.copy).not.toMatch(/[\u4e00-\u9fff]/); + }); }); diff --git a/web-next/lib/passport-login/view-model.ts b/web-next/lib/passport-login/view-model.ts index 75cfb85b56..821fc01e04 100644 --- a/web-next/lib/passport-login/view-model.ts +++ b/web-next/lib/passport-login/view-model.ts @@ -8,6 +8,24 @@ export function shouldBlockDefaultPasswordSubmit(needUpdatePassword: boolean, cr return !needUpdatePassword && shouldWarnDefaultPassword(credential); } +export function validateCredentialLoginDraft(identifier: string, credential: string, t: Translator) { + if (identifier.length === 0) { + return { + field: 'identifier' as const, + message: t('app.login.message-need-identifier') + }; + } + + if (credential.length === 0) { + return { + field: 'credential' as const, + message: t('app.login.message-need-credential') + }; + } + + return null; +} + export function buildLoginFeatureCards(t: Translator) { return [ { @@ -29,7 +47,7 @@ export function buildLoginNotice(needUpdatePassword: boolean, t: Translator) { if (needUpdatePassword) { return { kind: 'warning' as const, - copy: t('passport.login.default-password-warning'), + copy: t('app.login.need-change-password'), href: 'https://hertzbeat.apache.org/docs/start/account-modify' }; } diff --git a/web-next/lib/session-bff.test.ts b/web-next/lib/session-bff.test.ts new file mode 100644 index 0000000000..634cfc6d3f --- /dev/null +++ b/web-next/lib/session-bff.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildBackendApiUrl, + proxyBackendApiRequest, + sanitizeSessionPayload +} from './session-bff'; + +describe('session BFF helpers', () => { + const previousBackendOrigin = process.env.BACKEND_ORIGIN; + + afterEach(() => { + if (previousBackendOrigin === undefined) { + delete process.env.BACKEND_ORIGIN; + } else { + process.env.BACKEND_ORIGIN = previousBackendOrigin; + } + vi.unstubAllGlobals(); + }); + + it('builds backend API URLs from the configured private origin', () => { + process.env.BACKEND_ORIGIN = 'http://backend.internal:1157'; + + expect(buildBackendApiUrl('/config/system', '?locale=en-US')).toBe( + 'http://backend.internal:1157/api/config/system?locale=en-US' + ); + expect(buildBackendApiUrl('alerts')).toBe('http://backend.internal:1157/api/alerts'); + }); + + it('strips credential material from login and refresh payloads before returning them to browser code', () => { + expect( + sanitizeSessionPayload({ + code: 0, + data: { + token: 'access-token', + refreshToken: 'refresh-token', + user: 'admin' + } + }) + ).toEqual({ + code: 0, + data: { + user: 'admin', + authenticated: true, + tokenBoundary: 'bff-cookie' + } + }); + }); + + it('forwards the BFF access cookie as backend authorization when the framework cookie reader misses it', async () => { + const fetchMock = vi.fn<typeof fetch>().mockResolvedValue( + new Response(JSON.stringify({ code: 0, data: { ok: true } }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const request = { + url: 'http://127.0.0.1:4200/api/config/system?locale=zh-CN', + method: 'GET', + headers: new Headers({ + cookie: 'hb_ui_session=1; hb_ui_access=session-access-token; theme=dark', + 'accept-language': 'zh-CN' + }), + cookies: { + get: () => undefined + }, + arrayBuffer: vi.fn() + }; + + await proxyBackendApiRequest(request as any, '/config/system'); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:1157/api/config/system?locale=zh-CN', + expect.objectContaining({ + method: 'GET', + cache: 'no-store' + }) + ); + const forwardedHeaders = fetchMock.mock.calls[0][1]?.headers as Headers; + expect(forwardedHeaders.get('Authorization')).toBe('Bearer session-access-token'); + expect(forwardedHeaders.get('cookie')).toBeNull(); + }); + + it('does not forward backend auth challenges to browser-facing API responses', async () => { + const fetchMock = vi.fn<typeof fetch>().mockResolvedValue( + new Response('token expired', { + status: 401, + headers: { + 'Content-Type': 'text/plain', + 'WWW-Authenticate': 'Digest realm=sureness_realm,nonce=abc,qop=auth' + } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const request = { + url: 'http://127.0.0.1:4200/api/monitor', + method: 'GET', + headers: new Headers(), + cookies: { + get: () => undefined + }, + arrayBuffer: vi.fn() + }; + + const response = await proxyBackendApiRequest(request as any, '/monitor'); + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBeNull(); + expect(response.headers.get('Content-Type')).toContain('text/plain'); + }); + + it('refreshes the BFF access cookie and retries protected API calls when only refresh session remains', async () => { + const fetchMock = vi.fn<typeof fetch>() + .mockResolvedValueOnce( + new Response('token expired', { + status: 401, + headers: { + 'WWW-Authenticate': 'Digest realm=sureness_realm,nonce=abc,qop=auth' + } + }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + code: 0, + data: { + token: 'new-access-token', + refreshToken: 'new-refresh-token' + } + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: 0, data: { ok: true } }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const request = { + url: 'http://127.0.0.1:4200/api/config/system', + method: 'GET', + headers: new Headers({ + cookie: 'hb_ui_session=1; hb_ui_refresh=session-refresh-token', + 'accept-language': 'zh-CN' + }), + cookies: { + get: () => undefined + }, + arrayBuffer: vi.fn() + }; + + const response = await proxyBackendApiRequest(request as any, '/config/system'); + + expect(response.status).toBe(200); + expect(await response.text()).toContain('"ok":true'); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1][0]).toBe('http://127.0.0.1:1157/api/account/auth/refresh'); + expect(fetchMock.mock.calls[1][1]).toEqual( + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ token: 'session-refresh-token' }) + }) + ); + const retryHeaders = fetchMock.mock.calls[2][1]?.headers as Headers; + expect(retryHeaders.get('Authorization')).toBe('Bearer new-access-token'); + expect(response.headers.get('set-cookie')).toContain('hb_ui_access=new-access-token'); + expect(response.headers.get('set-cookie')).toContain('hb_ui_refresh=new-refresh-token'); + expect(response.headers.get('WWW-Authenticate')).toBeNull(); + }); +}); diff --git a/web-next/lib/session-bff.ts b/web-next/lib/session-bff.ts new file mode 100644 index 0000000000..b26af137a3 --- /dev/null +++ b/web-next/lib/session-bff.ts @@ -0,0 +1,206 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const HB_UI_ACCESS_COOKIE = 'hb_ui_access'; +export const HB_UI_REFRESH_COOKIE = 'hb_ui_refresh'; +export const HB_UI_SESSION_MARKER_COOKIE = 'hb_ui_session'; + +type ApiPayload = { + code?: number; + msg?: string; + data?: Record<string, unknown> | null; +}; + +type SessionTokens = { + token?: string; + refreshToken?: string; +}; + +const DEFAULT_BACKEND_ORIGIN = 'http://127.0.0.1:1157'; +const ACCESS_MAX_AGE_SECONDS = 60 * 60; +const REFRESH_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; + +function baseCookieOptions(maxAge: number) { + return { + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge + }; +} + +function secretCookieOptions(maxAge: number) { + return { + ...baseCookieOptions(maxAge), + httpOnly: true + }; +} + +function markerCookieOptions(maxAge: number) { + return { + ...baseCookieOptions(maxAge), + httpOnly: false + }; +} + +function readCookieHeaderValue(cookieHeader: string | null, name: string) { + if (!cookieHeader) return undefined; + const prefix = `${name}=`; + for (const segment of cookieHeader.split(';')) { + const trimmed = segment.trim(); + if (!trimmed.startsWith(prefix)) continue; + const rawValue = trimmed.slice(prefix.length); + if (rawValue.startsWith('"') && rawValue.endsWith('"')) { + return rawValue.slice(1, -1); + } + return rawValue; + } + return undefined; +} + +export function readSessionCookieValue(request: Pick<NextRequest, 'cookies' | 'headers'>, name: string) { + return request.cookies.get(name)?.value ?? readCookieHeaderValue(request.headers.get('cookie'), name); +} + +export function resolveBackendOrigin() { + return process.env.BACKEND_ORIGIN?.trim() || DEFAULT_BACKEND_ORIGIN; +} + +export function buildBackendApiUrl(path: string, search = '') { + const cleanPath = path.startsWith('/') ? path : `/${path}`; + return `${resolveBackendOrigin()}/api${cleanPath}${search}`; +} + +export function sanitizeSessionPayload(payload: ApiPayload): ApiPayload { + if (!payload.data || typeof payload.data !== 'object') { + return payload; + } + const { token: _token, refreshToken: _refreshToken, ...rest } = payload.data; + return { + ...payload, + data: { + ...rest, + authenticated: payload.code === 0, + tokenBoundary: 'bff-cookie' + } + }; +} + +export function applySessionCookies(response: NextResponse, tokens: SessionTokens) { + if (tokens.token) { + response.cookies.set(HB_UI_ACCESS_COOKIE, tokens.token, secretCookieOptions(ACCESS_MAX_AGE_SECONDS)); + } + if (tokens.refreshToken) { + response.cookies.set(HB_UI_REFRESH_COOKIE, tokens.refreshToken, secretCookieOptions(REFRESH_MAX_AGE_SECONDS)); + } + if (tokens.token || tokens.refreshToken) { + response.cookies.set(HB_UI_SESSION_MARKER_COOKIE, '1', markerCookieOptions(REFRESH_MAX_AGE_SECONDS)); + } +} + +export function clearSessionCookies(response: NextResponse) { + [HB_UI_ACCESS_COOKIE, HB_UI_REFRESH_COOKIE, HB_UI_SESSION_MARKER_COOKIE].forEach(name => { + const options = name === HB_UI_SESSION_MARKER_COOKIE ? markerCookieOptions(0) : secretCookieOptions(0); + response.cookies.set(name, '', options); + }); +} + +function copyProxyHeaders(request: NextRequest) { + const headers = new Headers(request.headers); + headers.delete('cookie'); + headers.delete('host'); + headers.delete('connection'); + headers.delete('content-length'); + return headers; +} + +function copyResponseHeaders(headers: Headers) { + const nextHeaders = new Headers(headers); + nextHeaders.delete('content-encoding'); + nextHeaders.delete('content-length'); + nextHeaders.delete('transfer-encoding'); + nextHeaders.delete('www-authenticate'); + nextHeaders.delete('proxy-authenticate'); + return nextHeaders; +} + +async function readProxyBody(request: NextRequest) { + if (request.method === 'GET' || request.method === 'HEAD') { + return undefined; + } + return request.arrayBuffer(); +} + +async function refreshSessionTokens(request: NextRequest): Promise<SessionTokens | null> { + const refreshToken = readSessionCookieValue(request, HB_UI_REFRESH_COOKIE); + if (!refreshToken) { + return null; + } + + const upstream = await fetch(buildBackendApiUrl('/account/auth/refresh'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(request.headers.get('Accept-Language') ? { 'Accept-Language': request.headers.get('Accept-Language') as string } : {}) + }, + body: JSON.stringify({ token: refreshToken }), + cache: 'no-store' + }); + const payload = await readJsonPayload(upstream); + if (!upstream.ok || payload.code !== 0 || !payload.data || typeof payload.data.token !== 'string') { + return null; + } + + return { + token: payload.data.token, + refreshToken: typeof payload.data.refreshToken === 'string' ? payload.data.refreshToken : refreshToken + }; +} + +export async function proxyBackendApiRequest(request: NextRequest, path: string) { + const requestUrl = new URL(request.url); + const body = await readProxyBody(request); + const accessToken = readSessionCookieValue(request, HB_UI_ACCESS_COOKIE); + + const fetchUpstream = (token?: string) => { + const headers = copyProxyHeaders(request); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + return fetch(buildBackendApiUrl(path, requestUrl.search), { + method: request.method, + headers, + body, + redirect: 'manual', + cache: 'no-store' + }); + }; + + let upstream = await fetchUpstream(accessToken); + let refreshedTokens: SessionTokens | null = null; + if (upstream.status === 401) { + refreshedTokens = await refreshSessionTokens(request); + if (refreshedTokens?.token) { + upstream = await fetchUpstream(refreshedTokens.token); + } + } + + const response = new NextResponse(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: copyResponseHeaders(upstream.headers) + }); + if (refreshedTokens?.token && upstream.ok) { + applySessionCookies(response, refreshedTokens); + } else if (upstream.status === 401 && readSessionCookieValue(request, HB_UI_REFRESH_COOKIE)) { + clearSessionCookies(response); + } + return response; +} + +export async function readJsonPayload(response: Response): Promise<ApiPayload> { + try { + return (await response.json()) as ApiPayload; + } catch { + return { code: response.ok ? 0 : response.status, msg: response.statusText, data: null }; + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
