[web] align branded sign-in page #193
No files matched your search
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"')
|
||||
})
|
||||
Reference in new issue
Block a user