[web] align branded sign-in page #193

Merged
eleith merged 1 commits from refactor/signin-page into main 2026-11-04 20:49:16 +00:00
5 changed files with 376 additions and 125 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Give sign-in a responsive branded card, consistent credential and single sign-on forms, clear error and pending states, and simple return/theme controls instead of the public search navbar. Keep client-side authentication and validated redirects unchanged.
+83 -5
View File
@@ -1,11 +1,89 @@
<script lang="ts">
import Nav from '#lib/components/layout/simple/nav.svelte'
import { resolve } from '$app/paths'
import ArrowLeftIcon from 'virtual:icons/ph/arrow-left'
import SeoHead from '#lib/components/layout/simple/SeoHead.svelte'
import ThemeToggle from '#lib/components/layout/simple/ThemeToggle.svelte'
const { children } = $props()
</script>
<Nav />
<SeoHead />
<main id="main-content">
{@render children()}
</main>
<div class="signin-shell">
<header class="signin-tools">
<a href={resolve('')}>
<ArrowLeftIcon aria-hidden="true" focusable="false" />
Back to site
</a>
<ThemeToggle />
</header>
<main id="main-content">
{@render children()}
</main>
</div>
<style>
.signin-shell {
min-height: 100svh;
display: flex;
flex-direction: column;
}
.signin-tools {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-5);
color: var(--color-on-surface-variant);
}
.signin-tools a {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
color: inherit;
text-decoration: none;
font-size: var(--font-size-small);
}
.signin-tools a:hover {
text-decoration: underline;
}
.signin-tools :global(button) {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: none;
border-radius: var(--radius-small);
background: transparent;
color: inherit;
font-size: var(--font-size-large);
cursor: pointer;
}
.signin-tools a:focus-visible,
.signin-tools :global(button:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: var(--space-6) var(--space-4) var(--space-8);
}
@media (max-width: 30rem) {
main {
align-items: flex-start;
padding-block-start: var(--space-5);
}
}
</style>
+191 -74
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import WarningIcon from 'virtual:icons/ph/warning-circle'
import Button from '#lib/components/ui/Button.svelte'
import { signInCredentials, signInOidc, type AuthFailure } from '#lib/auth-client.js'
import { goto } from '$app/navigation'
@@ -6,16 +8,28 @@
let { data }: PageProps = $props()
const redirectTo = $derived(data.redirectTo)
const site = $derived(data.config.content)
let form: HTMLFormElement | undefined = $state()
let username = $state('')
let password = $state('')
let pending = $state(false)
let submitError = $state<AuthFailure | 'NavigationError' | null>(null)
const errorCode = $derived(submitError ?? data.errorCode)
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
onMount(() => {
if (!window.matchMedia('(min-width: 48rem) and (pointer: fine)').matches) return
form?.querySelector<HTMLElement>('input[name="username"], button[type="submit"]')?.focus()
})
async function handleSubmit(event: SubmitEvent) {
event.preventDefault()
if (pending) return
if (data.authType === 'oidc') {
await handleOidc()
return
}
pending = true
submitError = null
const failure = await signInCredentials(username, password)
@@ -47,120 +61,223 @@
}
</script>
<svelte:head>
<title>Sign in — {site.text.title}</title>
<meta name="robots" content="noindex" />
</svelte:head>
<svelte:window
onpageshow={(event) => {
if (event.persisted) pending = false
}}
/>
{#snippet errorBanner()}
<section class="signin-card" aria-labelledby="signin-title">
<header class="signin-header">
<img src={site.images.favicon} alt="" width="48" height="48" />
<h1 id="signin-title">Sign in to {site.text.title}</h1>
<p>Manage and publish your archive.</p>
</header>
{#if errorCode}
<div class="error" role="alert">
{#if errorCode === 'NavigationError'}
<!-- eslint-disable svelte/no-navigation-without-resolve -- Use the same validated destination as the server redirect. -->
Signed in. <a href={redirectTo}>Continue</a> when ready.
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else if errorCode === 'CredentialsSignin'}
Invalid username or password
{:else if errorCode === 'RateLimited'}
Too many sign-in attempts. Please wait and try again.
{:else if errorCode === 'NetworkError'}
Unable to connect. Please try again.
{:else}
Unable to sign in. Please try again.
{/if}
<div class="error-banner" role="alert">
<WarningIcon aria-hidden="true" focusable="false" />
<div>
{#if errorCode === 'NavigationError'}
<!-- eslint-disable svelte/no-navigation-without-resolve -- Use the same validated destination as the server redirect. -->
Signed in. <a href={redirectTo}>Continue</a> when ready.
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else if errorCode === 'CredentialsSignin'}
Invalid username or password. Please try again.
{:else if errorCode === 'RateLimited'}
Too many sign-in attempts. Please wait and try again.
{:else if errorCode === 'NetworkError'}
Unable to connect. Please try again.
{:else}
Unable to sign in. Please try again.
{/if}
</div>
</div>
{/if}
{/snippet}
<h1 class="visually-hidden">Login</h1>
<section>
{#if data.authType === 'oidc'}
<div class="oidc-login">
{@render errorBanner()}
<p>login via {data.oidcName}</p>
<Button onclick={handleOidc} disabled={pending}>login</Button>
</div>
{:else if data.authType === 'credentials'}
<form onsubmit={handleSubmit} class="login-form" aria-busy={pending}>
{@render errorBanner()}
<form
method="POST"
onsubmit={handleSubmit}
bind:this={form}
class="signin-form"
aria-busy={pending}
>
{#if data.authType === 'credentials'}
<div class="field">
<label for="username">username</label>
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
bind:value={username}
class="input"
autocomplete="username"
disabled={pending}
required
/>
</div>
<div class="field">
<label for="password">password</label>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
bind:value={password}
class="input"
autocomplete="current-password"
disabled={pending}
required
/>
</div>
<Button type="submit" disabled={pending}>login</Button>
</form>
{/if}
{:else if data.authType === 'oidc'}
<p class="sso-description">Use {data.oidcName} to access your archive.</p>
{/if}
<Button type="submit" disabled={pending}>
{#if pending}
Signing in…
{:else if data.authType === 'oidc'}
Sign in with {data.oidcName}
{:else}
Sign in
{/if}
</Button>
</form>
<noscript><p class="javascript-notice">JavaScript is required to sign in.</p></noscript>
</section>
<style>
section {
display: flex;
height: calc(100vh - 300px);
justify-content: center;
align-items: center;
}
.oidc-login {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
}
.oidc-login p {
margin: 0;
font-size: var(--font-size-xs);
text-transform: uppercase;
}
.login-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
.signin-card {
width: 100%;
max-width: 300px;
padding: var(--space-6);
max-width: 25rem;
min-width: 0;
display: grid;
gap: var(--space-6);
padding: var(--space-8);
background: var(--color-surface-container-low);
border-radius: var(--radius-medium);
border: 1px solid var(--color-outline);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-large);
box-shadow: var(--shadow-raised);
}
.signin-header {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
text-align: center;
}
.signin-header img {
object-fit: contain;
}
h1 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
line-height: 1.2;
overflow-wrap: anywhere;
}
.signin-header p,
.sso-description,
.javascript-notice {
margin: 0;
color: var(--color-on-surface-variant);
font-size: var(--font-size-small);
line-height: 1.5;
text-align: center;
}
.signin-form {
display: grid;
gap: var(--space-5);
}
.field {
display: grid;
gap: var(--space-2);
min-width: 0;
}
label {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
input {
width: 100%;
min-width: 0;
min-height: 50px;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-outline);
border-radius: var(--radius-small);
background: var(--color-surface-container-highest);
color: var(--color-on-surface);
font: inherit;
font-size: 16px;
}
input:focus-visible,
.signin-form :global(.button:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
input:disabled {
opacity: 0.6;
}
.signin-form :global(.button) {
display: flex;
flex-direction: column;
gap: var(--space-1);
align-items: center;
justify-content: center;
width: 100%;
height: auto;
min-height: 50px;
padding: var(--space-3) var(--space-4);
text-transform: none;
white-space: normal;
overflow-wrap: anywhere;
font-size: var(--font-size-normal);
line-height: 1.4;
}
.field label {
font-size: var(--font-size-xxs);
text-transform: uppercase;
.error-banner {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-error);
border-radius: var(--radius-small);
background: var(--color-error-container);
color: var(--color-on-error-container);
font-size: var(--font-size-small);
line-height: 1.5;
overflow-wrap: anywhere;
}
.error {
color: var(--color-error);
font-size: var(--font-size-xs);
margin-bottom: var(--space-2);
.error-banner :global(svg) {
flex-shrink: 0;
width: 1.25em;
height: 1.25em;
}
.error-banner a {
color: inherit;
}
@media (max-width: 30rem) {
.signin-card {
padding: var(--space-4);
border: none;
box-shadow: none;
background: transparent;
}
}
</style>
@@ -0,0 +1,11 @@
<script lang="ts">
import type { ComponentProps } from 'svelte'
import Layout from '../+layout.svelte'
import Signin from '../+page.svelte'
let { data }: { data: ComponentProps<typeof Signin>['data'] } = $props()
</script>
<Layout>
<Signin {data} params={{}} form={null} />
</Layout>
+86 -46
View File
@@ -1,58 +1,98 @@
import { readFileSync } from 'node:fs'
import { parse } from 'svelte/compiler'
import { afterAll, expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import type { ConfigPublic } from '@luzzle/web.config'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import Signin from './+page.svelte'
import SigninTree from './fixtures/SigninTree.svelte'
const { page } = vi.hoisted(() => ({
page: {
url: new URL('https://app.test/signin'),
data: {
config: undefined as ConfigPublic | undefined,
meta: { title: 'Test Garden', description: 'An archive' }
}
}
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/env', () => ({ browser: false }))
vi.mock('$app/paths', () => ({ resolve: (path: string) => (path === '' ? '/' : path) }))
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
// Import the real client during SSR without consulting ambient auth URL settings.
// Client authentication must remain lazy during SSR, even with invalid ambient settings.
vi.hoisted(() => vi.stubEnv('BETTER_AUTH_URL', 'not-a-url'))
afterAll(() => vi.unstubAllEnvs())
const source = readFileSync(new URL('./+page.svelte', import.meta.url), 'utf8')
function signinData(): ComponentProps<typeof Signin>['data'] {
const config = makePublicConfig()
config.content.text.title = 'Test Garden'
config.content.images.favicon = '/assets/favicon/garden.svg'
return {
pieceTypes: [],
config,
meta: { title: 'Test Garden', description: 'An archive' },
redirectTo: '/admin',
authType: 'credentials',
oidcName: undefined,
errorCode: null
}
}
// Structural guards: SSR cannot exercise clicks or client-side navigation.
test('the client destination reacts to validated page data, not raw query parameters', () => {
const statements = parse(source, { modern: true }).instance?.content.body ?? []
const declaration = statements
.flatMap((statement) =>
statement.type === 'VariableDeclaration' ? statement.declarations : []
)
.find(
(declaration) => declaration.id.type === 'Identifier' && declaration.id.name === 'redirectTo'
)
test('credentials use configured branding and named POST controls with password-manager hints', () => {
const data = signinData()
expect(declaration).toMatchObject({
init: {
type: 'CallExpression',
callee: { type: 'Identifier', name: '$derived' },
arguments: [
{
type: 'MemberExpression',
object: { type: 'Identifier', name: 'data' },
property: { type: 'Identifier', name: 'redirectTo' }
}
]
}
})
expect(source).not.toContain('searchParams')
const result = render(Signin, { props: { data, params: {}, form: null } })
const html = result.body.replace(/<!--[\s\S]*?-->/g, '')
expect(html).toContain('Sign in to Test Garden')
expect(html).toContain('src="/assets/favicon/garden.svg"')
expect(html).toMatch(/<form\b[^>]*\bmethod="post"/i)
expect(html).toMatch(/<input\b(?=[^>]*\bname="username")(?=[^>]*\bautocomplete="username")[^>]*>/)
expect(html).toMatch(
/<input\b(?=[^>]*\bname="password")(?=[^>]*\btype="password")(?=[^>]*\bautocomplete="current-password")[^>]*>/
)
expect(result.head).toContain('Sign in — Test Garden')
})
test.each(['credentials', 'oidc'])(
'SSR with an invalid ambient auth URL renders an accessible callback failure for %s',
(authType) => {
const { body } = render(Signin, {
props: {
data: { redirectTo: '/admin', authType, oidcName: 'Our SSO', errorCode: 'AuthError' }
} as never
})
expect(body).toContain('role="alert"')
expect(body).toContain('Unable to sign in. Please try again.')
if (authType === 'credentials') {
expect(body).toContain('autocomplete="username"')
expect(body).toContain('autocomplete="current-password"')
} else {
expect(body).toContain('Our SSO')
}
}
)
test('OIDC offers the selected provider without presenting local credential fields', () => {
const data = signinData()
data.authType = 'oidc'
data.oidcName = 'Our SSO'
const { body } = render(Signin, { props: { data, params: {}, form: null } })
const html = body.replace(/<!--[\s\S]*?-->/g, '')
expect(html).toContain('Sign in with Our SSO')
expect(html).toMatch(/<button\b[^>]*\btype="submit"/)
expect(html).not.toMatch(/<input\b/)
})
test('a callback failure is announced without removing the selected sign-in method', () => {
const data = signinData()
data.authType = 'oidc'
data.oidcName = 'Our SSO'
data.errorCode = 'AuthError'
const { body } = render(Signin, { props: { data, params: {}, form: null } })
const html = body.replace(/<!--[\s\S]*?-->/g, '')
expect(html).toContain('role="alert"')
expect(html).toContain('Unable to sign in. Please try again.')
expect(html).toContain('Sign in with Our SSO')
})
test('the sign-in layout keeps branding, theme and return navigation without public search', () => {
const data = signinData()
page.data.config = data.config
const result = render(SigninTree, { props: { data } })
expect(result.body.match(/<main\b/g)).toHaveLength(1)
expect(result.body).toContain('href="/"')
expect(result.body).toContain('Back to site')
expect(result.body).toContain('aria-label="change theme"')
expect(result.body).not.toContain('aria-label="search"')
expect(result.head).toContain('rel="icon" href="/assets/favicon/garden.svg"')
expect(result.head).toContain('Sign in — Test Garden')
expect(result.head).toContain('name="robots" content="noindex"')
})