From a72179ad4dee820e3864d44d02824a3c84b2e66d Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:11:55 +0000 Subject: [PATCH] feat(auth): rebuild the sign-in pages as a bookplate on an endpaper Put every auth page on a shared Ex Libris plate with signature-line fields, a show-password toggle and a Caps Lock hint. Show sign-in messages outside the password form, with Retry, a reset link on lockout and a reset confirmation. Add language and endpaper pickers (eight repeats) to the auth toolbar, replacing the background and corner controls. --- client/src/assets/theme/tokens.css | 9 + .../src/features/auth/ForgotPasswordPage.vue | 79 ++---- client/src/features/auth/LoginPage.vue | 243 ++++++++++-------- .../src/features/auth/MagicLinkLoginView.vue | 36 ++- client/src/features/auth/OidcCallbackPage.vue | 36 +-- client/src/features/auth/RegisterPage.vue | 169 +++++------- .../src/features/auth/ResetPasswordPage.vue | 111 ++++---- client/src/features/auth/SetupPage.vue | 152 +++-------- .../features/auth/__tests__/LoginPage.spec.ts | 81 +++++- .../features/auth/components/AuthAlert.vue | 20 ++ .../auth/components/AuthAppearanceToolbar.vue | 86 ++++--- .../features/auth/components/AuthEndpaper.vue | 73 ++++++ .../auth/components/AuthEndpaperPicker.vue | 39 +++ .../features/auth/components/AuthField.vue | 146 +++++++++++ .../features/auth/components/AuthPlate.vue | 92 +++++++ .../features/auth/components/AuthSubmit.vue | 27 ++ .../__tests__/AuthAppearanceToolbar.spec.ts | 52 ++-- .../__tests__/AuthEndpaperPicker.spec.ts | 54 ++++ .../components/__tests__/AuthField.spec.ts | 54 ++++ .../features/auth/composables/useEndpaper.ts | 22 ++ client/src/features/auth/lib/endpapers.ts | 76 ++++++ client/src/locales/en.json | 23 +- 22 files changed, 1118 insertions(+), 562 deletions(-) create mode 100644 client/src/features/auth/components/AuthAlert.vue create mode 100644 client/src/features/auth/components/AuthEndpaper.vue create mode 100644 client/src/features/auth/components/AuthEndpaperPicker.vue create mode 100644 client/src/features/auth/components/AuthField.vue create mode 100644 client/src/features/auth/components/AuthPlate.vue create mode 100644 client/src/features/auth/components/AuthSubmit.vue create mode 100644 client/src/features/auth/components/__tests__/AuthEndpaperPicker.spec.ts create mode 100644 client/src/features/auth/components/__tests__/AuthField.spec.ts create mode 100644 client/src/features/auth/composables/useEndpaper.ts create mode 100644 client/src/features/auth/lib/endpapers.ts diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index aca3ea004..5ae1ab0a7 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -331,6 +331,11 @@ --medal-blank-glyph: color-mix(in oklch, var(--muted-foreground) 70%, var(--card)); --medal-shadow: oklch(0.2 0.02 260 / 0.22); --shelf-line: color-mix(in oklch, var(--foreground) 14%, transparent); + /* Sign-in pages: a bookplate pasted on an endpaper. Light mode prints the plate on a warm sheet. */ + --plate: oklch(0.975 0.012 84); + --plate-rule: color-mix(in oklch, var(--foreground) 42%, transparent); + --endpaper: color-mix(in oklch, var(--primary) 5%, var(--background)); + --endpaper-ink: color-mix(in oklch, var(--primary) 14%, var(--background)); } @media (min-width: 40rem) { @@ -548,4 +553,8 @@ --medal-blank-glyph: color-mix(in oklch, var(--muted-foreground) 62%, var(--card)); --medal-shadow: oklch(0 0 0 / 0.55); --shelf-line: color-mix(in oklch, var(--foreground) 12%, transparent); + --plate: var(--card); + --plate-rule: color-mix(in oklch, var(--foreground) 34%, transparent); + --endpaper: color-mix(in oklch, var(--primary) 7%, var(--background)); + --endpaper-ink: color-mix(in oklch, var(--primary) 17%, var(--background)); } diff --git a/client/src/features/auth/ForgotPasswordPage.vue b/client/src/features/auth/ForgotPasswordPage.vue index f61dcd365..9d2338b30 100644 --- a/client/src/features/auth/ForgotPasswordPage.vue +++ b/client/src/features/auth/ForgotPasswordPage.vue @@ -1,9 +1,12 @@ - - diff --git a/client/src/features/auth/LoginPage.vue b/client/src/features/auth/LoginPage.vue index 8c5193af1..bda898aa3 100644 --- a/client/src/features/auth/LoginPage.vue +++ b/client/src/features/auth/LoginPage.vue @@ -1,32 +1,44 @@ - - diff --git a/client/src/features/auth/MagicLinkLoginView.vue b/client/src/features/auth/MagicLinkLoginView.vue index 83d773efc..afe2ca6ae 100644 --- a/client/src/features/auth/MagicLinkLoginView.vue +++ b/client/src/features/auth/MagicLinkLoginView.vue @@ -2,9 +2,10 @@ import { ref, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' -import { Loader2, AlertCircle } from '@lucide/vue' +import { CircleAlert, LoaderCircle } from '@lucide/vue' import { useAuth } from '@/features/auth/composables/useAuth' -import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue' +import AuthAlert from './components/AuthAlert.vue' +import AuthPlate from './components/AuthPlate.vue' const { t } = useI18n() const route = useRoute() @@ -42,29 +43,22 @@ function goToLogin() { diff --git a/client/src/features/auth/OidcCallbackPage.vue b/client/src/features/auth/OidcCallbackPage.vue index 3aaa209d7..b60d4ae3a 100644 --- a/client/src/features/auth/OidcCallbackPage.vue +++ b/client/src/features/auth/OidcCallbackPage.vue @@ -5,7 +5,9 @@ import { useRouter } from 'vue-router' import type { OidcCallbackResponse } from '@bookorbit/types' import { OidcErrorCode } from '@bookorbit/types' import { setAccessToken } from '@/lib/api' -import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue' +import { CircleAlert, LoaderCircle } from '@lucide/vue' +import AuthAlert from './components/AuthAlert.vue' +import AuthPlate from './components/AuthPlate.vue' import { useAuth } from './composables/useAuth' import { useOidc, OidcLoginError } from './composables/useOidc' @@ -83,22 +85,20 @@ onMounted(async () => { diff --git a/client/src/features/auth/RegisterPage.vue b/client/src/features/auth/RegisterPage.vue index 98d0134d9..bf31e9c49 100644 --- a/client/src/features/auth/RegisterPage.vue +++ b/client/src/features/auth/RegisterPage.vue @@ -1,8 +1,11 @@ - - diff --git a/client/src/features/auth/ResetPasswordPage.vue b/client/src/features/auth/ResetPasswordPage.vue index 64e8faf45..6c8165086 100644 --- a/client/src/features/auth/ResetPasswordPage.vue +++ b/client/src/features/auth/ResetPasswordPage.vue @@ -1,10 +1,13 @@ - - diff --git a/client/src/features/auth/SetupPage.vue b/client/src/features/auth/SetupPage.vue index 9f4eb4cae..b363c01b4 100644 --- a/client/src/features/auth/SetupPage.vue +++ b/client/src/features/auth/SetupPage.vue @@ -1,8 +1,11 @@ - - diff --git a/client/src/features/auth/__tests__/LoginPage.spec.ts b/client/src/features/auth/__tests__/LoginPage.spec.ts index d7d6dad7b..0997129fc 100644 --- a/client/src/features/auth/__tests__/LoginPage.spec.ts +++ b/client/src/features/auth/__tests__/LoginPage.spec.ts @@ -2,16 +2,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import { flushPromises, mount } from '@vue/test-utils' +import { LoginErrorCode } from '@bookorbit/types' import LoginPage from '../LoginPage.vue' +import { LoginError } from '../composables/useAuth' -const { statusState, routeState } = vi.hoisted(() => ({ - statusState: { allowRegistration: false, passwordLoginEnabled: true }, +const { statusState, routeState, loginMock } = vi.hoisted(() => ({ + statusState: { allowRegistration: false, passwordLoginEnabled: true, optionsFail: false }, routeState: { query: {} as Record }, + loginMock: vi.fn<(username: string, password: string) => Promise>(), })) +// Same constructor as the real LoginError; importing the real module pulls in the router. vi.mock('../composables/useAuth', () => ({ - LoginError: class LoginError extends Error {}, - useAuth: () => ({ login: vi.fn<() => void>() }), + LoginError: class LoginError extends Error { + constructor( + public readonly errorCode: string | undefined, + public readonly retryAfterSeconds: number | undefined, + message: string, + ) { + super(message) + } + }, + useAuth: () => ({ login: loginMock }), })) vi.mock('../composables/useOidc', () => ({ @@ -28,7 +40,10 @@ vi.mock('../composables/useLoginOptions', () => ({ return { loginOptions: ref(options), loginOptionsError: ref(null), - fetchLoginOptions: vi.fn<() => Promise>(async () => options), + fetchLoginOptions: vi.fn<() => Promise>(async () => { + if (statusState.optionsFail) throw new Error('Failed to load sign-in options') + return options + }), } }, })) @@ -37,6 +52,7 @@ vi.mock('../composables/useSetupStatus', () => ({ useSetupStatus: () => ({ allowRegistration: ref(statusState.allowRegistration), setupStatusError: ref(null), + fetchSetupStatus: vi.fn<() => Promise>(async () => false), }), })) @@ -62,7 +78,9 @@ beforeEach(() => { vi.clearAllMocks() statusState.allowRegistration = false statusState.passwordLoginEnabled = true + statusState.optionsFail = false routeState.query = {} + loginMock.mockResolvedValue(undefined) }) describe('LoginPage sign-up affordance', () => { @@ -129,3 +147,56 @@ describe('LoginPage post-registration notice', () => { expect(wrapper.find('[role="status"]').exists()).toBe(false) }) }) + +describe('LoginPage messages', () => { + it('confirms a password reset', async () => { + routeState.query = { reset: '1' } + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.find('[role="status"]').text()).toContain('Password updated') + }) + + it('reports a failed options load with a retry even when password sign-in is off', async () => { + statusState.passwordLoginEnabled = false + statusState.optionsFail = true + const wrapper = mountPage() + await flushPromises() + + const alert = wrapper.get('[role="alert"]') + expect(alert.text()).toContain('Sign-in options could not be loaded') + + statusState.optionsFail = false + await alert.get('button').trigger('click') + await flushPromises() + + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) + + it('offers a password reset when the account is locked', async () => { + loginMock.mockRejectedValue(new LoginError(LoginErrorCode.ACCOUNT_LOCKED, 900, 'Account locked')) + const wrapper = mountPage() + await flushPromises() + + await wrapper.get('form').trigger('submit') + await flushPromises() + + const alert = wrapper.get('[role="alert"]') + expect(alert.text()).toContain('15 minutes') + const reset = alert.findAll('a').find((a) => a.attributes('href') === '/forgot-password') + expect(reset?.text()).toBe('Reset password') + }) + + it('marks the password field invalid after rejected credentials', async () => { + loginMock.mockRejectedValue(new Error('Invalid credentials')) + const wrapper = mountPage() + await flushPromises() + + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(wrapper.get('[role="alert"]').text()).toBe('Invalid username or password') + expect(wrapper.get('#password').attributes('aria-invalid')).toBe('true') + expect(wrapper.get('#password').attributes('aria-describedby')).toContain('login-error') + }) +}) diff --git a/client/src/features/auth/components/AuthAlert.vue b/client/src/features/auth/components/AuthAlert.vue new file mode 100644 index 000000000..a7dbdd0d5 --- /dev/null +++ b/client/src/features/auth/components/AuthAlert.vue @@ -0,0 +1,20 @@ + + + diff --git a/client/src/features/auth/components/AuthAppearanceToolbar.vue b/client/src/features/auth/components/AuthAppearanceToolbar.vue index a97435788..67fe0fd03 100644 --- a/client/src/features/auth/components/AuthAppearanceToolbar.vue +++ b/client/src/features/auth/components/AuthAppearanceToolbar.vue @@ -1,32 +1,64 @@