test(frontend): correct test to fit sign up/in

This commit is contained in:
Edouard Vanbelle
2026-07-14 03:21:18 +02:00
parent 691d01a458
commit 341e354162
4 changed files with 475 additions and 449 deletions
+428 -434
View File
@@ -360,496 +360,490 @@
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div> <div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div> </div>
<!-- Form paints immediately alongside the logo — the onMount <!-- Form paints immediately alongside the logo — the onMount
probes (OIDC code exchange, session probe, providers probes (OIDC code exchange, session probe, providers
lookup) run concurrently and either redirect the user lookup) run concurrently and either redirect the user
away or upgrade the visible affordances (OIDC button, away or upgrade the visible affordances (OIDC button,
magic-link toggle) in place. Guarding the whole form magic-link toggle) in place. Guarding the whole form
behind `booting` caused a "logo only, then form" flash behind `booting` caused a "logo only, then form" flash
on first paint. --> on first paint. -->
<h1 class="auth-title"> <h1 class="auth-title">
{#if mode === 'login'}
{t('auth.sign_in', 'Sign in')}
{:else if mode === 'register'}
{t('auth.register', 'Create account')}
{:else}
{t('auth.setup_title', 'Initial setup')}
{/if}
</h1>
{#if sessionExpiredNotice}
<div
class="auth-error auth-error--dismissible"
style="display: flex"
role="alert"
data-testid="login-session-expired-notice"
>
<span>{t('auth.session_expired', 'Your session expired. Please sign in again.')}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-session-expired-dismiss-btn"
onclick={() => (sessionExpiredNotice = false)}>×</button
>
</div>
{/if}
{#if postRegisterNotice && mode === 'login'}
<div
class="auth-success auth-error--dismissible"
style="display: flex"
role="status"
data-testid="login-post-register-notice"
>
<span>{postRegisterNotice}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-post-register-dismiss-btn"
onclick={() => (postRegisterNotice = null)}>×</button
>
</div>
{/if}
{#if mode === 'login'} {#if mode === 'login'}
<!-- Unified login form. One identifier + one (optional) {t('auth.sign_in', 'Sign in')}
{:else if mode === 'register'}
{t('auth.register', 'Create account')}
{:else}
{t('auth.setup_title', 'Initial setup')}
{/if}
</h1>
{#if sessionExpiredNotice}
<div
class="auth-error auth-error--dismissible"
style="display: flex"
role="alert"
data-testid="login-session-expired-notice"
>
<span>{t('auth.session_expired', 'Your session expired. Please sign in again.')}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-session-expired-dismiss-btn"
onclick={() => (sessionExpiredNotice = false)}>×</button
>
</div>
{/if}
{#if postRegisterNotice && mode === 'login'}
<div
class="auth-success auth-error--dismissible"
style="display: flex"
role="status"
data-testid="login-post-register-notice"
>
<span>{postRegisterNotice}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-post-register-dismiss-btn"
onclick={() => (postRegisterNotice = null)}>×</button
>
</div>
{/if}
{#if mode === 'login'}
<!-- Unified login form. One identifier + one (optional)
password field drive both flows: password field drive both flows:
* password filled → POST /api/auth/login * password filled → POST /api/auth/login
* password empty → POST /api/auth/magic-link/send * password empty → POST /api/auth/magic-link/send
* password-only server → password field is required, no hint * password-only server → password field is required, no hint
* magic-link-only server → password field hides entirely --> * magic-link-only server → password field hides entirely -->
{#if passwordLoginEnabled || magicLinkLoginEnabled} {#if passwordLoginEnabled || magicLinkLoginEnabled}
{#if error} {#if error}
<div <div
class={emailNotVerified ? 'auth-success' : 'auth-error'} class={emailNotVerified ? 'auth-success' : 'auth-error'}
style="display: block" style="display: block"
role="alert" role="alert"
>
{error}
</div>
{/if}
{#if magicStatus}
<div
class={magicStatus.ok
? 'auth-status auth-status-success'
: 'auth-status auth-status-error'}
role={magicStatus.ok ? 'status' : 'alert'}
>
{magicStatus.text}
</div>
{/if}
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.login_identifier', 'Username or email')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="login-username"
class="auth-input"
data-testid="login-username-input"
type="text"
bind:value={username}
bind:this={loginIdentifierInput}
autocomplete="username"
placeholder={t(
'auth.login_identifier_placeholder',
'Enter your username or email'
)}
required
disabled={busy}
/>
</div>
</div>
{#if passwordLoginEnabled}
<div class="auth-input-group">
<label class="auth-label" for="login-password">
{#if magicLinkLoginEnabled}
{t('auth.password_or_link_hint', 'Password (leave blank for a sign-in link)')}
{:else}
{t('auth.password', 'Password')}
{/if}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="login-password"
class="auth-input"
data-testid="login-password-input"
type={showPassword ? 'text' : 'password'}
bind:value={password}
onkeydown={onPwKey}
onkeyup={onPwKey}
autocomplete="current-password"
required={!magicLinkLoginEnabled}
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={showPassword}
data-testid="login-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (showPassword = !showPassword)}
></button>
</div>
{#if capsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
{/if}
<button
class="auth-button"
type="submit"
data-testid="login-submit-btn"
disabled={busy}
aria-busy={busy}
>
{#if busy}
{submitAsMagicLink
? t('auth.sending', 'Sending…')
: t('auth.signing_in', 'Signing in…')}
{:else if submitAsMagicLink}
{t('auth.magicLinkSubmit', 'Send sign-in link')}
{:else}
{t('auth.sign_in', 'Sign in')}
{/if}
</button>
</form>
{/if}
{#if oidc.enabled}
{#if passwordLoginEnabled}
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
<a
class="auth-button auth-button-oidc"
data-testid="login-oidc-btn"
href={oidc.authorize_endpoint}
rel="external"
> >
{t( {error}
'auth.sso_login_provider',
{ provider: oidc.provider_name ?? 'SSO' },
'Sign in with {{provider}}'
)}
</a>
{/if}
{#if passwordLoginEnabled}
<div class="auth-toggle">
{t('auth.no_account', 'No account?')}
<button
class="auth-toggle-link"
data-testid="login-to-register-btn"
onclick={() => (mode = 'register')}
>
{t('auth.register', 'Create one')}
</button>
</div> </div>
{/if} {/if}
{#if magicStatus}
{#if setupAvailable} <div
<div class="auth-toggle"> class={magicStatus.ok
{t('auth.admin_setup', 'First time?')} ? 'auth-status auth-status-success'
<button : 'auth-status auth-status-error'}
class="auth-toggle-link" role={magicStatus.ok ? 'status' : 'alert'}
data-testid="login-to-setup-btn" >
onclick={() => (mode = 'setup')} {magicStatus.text}
>
{t('auth.setup', 'Set up administrator')}
</button>
</div> </div>
{/if} {/if}
{:else if mode === 'register'} <form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
{#if regError}<div class="auth-error" style="display: block" role="alert">
{regError}
</div>{/if}
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
<!-- Email is the only required identifier since PR 18 — the
backend accepts email-only signup and mints a welcome
magic-link. Username is optional at this stage; the user
can claim a handle later via profile settings. -->
<div class="auth-input-group"> <div class="auth-input-group">
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label> <label class="auth-label" for="login-username">
<input {t('auth.login_identifier', 'Username or email')}
id="reg-email"
class="auth-input"
data-testid="login-register-email-input"
type="email"
bind:value={regEmail}
bind:this={registerEmailInput}
autocomplete="email"
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-username">
{t('auth.username_optional', 'Username (optional)')}
</label> </label>
<input <div class="auth-input-wrap auth-input-wrap--user">
id="reg-username" <input
class="auth-input" id="login-username"
data-testid="login-register-username-input" class="auth-input"
bind:value={regUsername} data-testid="login-username-input"
autocomplete="username" type="text"
disabled={busy} bind:value={username}
/> bind:this={loginIdentifierInput}
autocomplete="username"
placeholder={t('auth.login_identifier_placeholder', 'Enter your username or email')}
required
disabled={busy}
/>
</div>
</div> </div>
<!-- Password fields hide entirely when policy forbids password
login — the whole form becomes email-only in that mode. -->
{#if passwordLoginEnabled} {#if passwordLoginEnabled}
<div class="auth-input-group"> <div class="auth-input-group">
<label class="auth-label" for="reg-password"> <label class="auth-label" for="login-password">
{t( {#if magicLinkLoginEnabled}
'auth.password_optional', {t('auth.password_or_link_hint', 'Password (leave blank for a sign-in link)')}
'Password (optional — leave blank for a sign-in link)' {:else}
)} {t('auth.password', 'Password')}
{/if}
</label> </label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle"> <div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input <input
id="reg-password" id="login-password"
class="auth-input" class="auth-input"
data-testid="login-register-password-input" data-testid="login-password-input"
type={regShowPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
bind:value={regPassword} bind:value={password}
onkeydown={onRegPwKey} onkeydown={onPwKey}
onkeyup={onRegPwKey} onkeyup={onPwKey}
autocomplete="new-password" autocomplete="current-password"
required={!magicLinkLoginEnabled}
disabled={busy} disabled={busy}
/> />
<button <button
type="button" type="button"
class="auth-pw-toggle" class="auth-pw-toggle"
aria-pressed={regShowPassword} aria-pressed={showPassword}
data-testid="login-register-password-toggle-btn" data-testid="login-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')} aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)} onclick={() => (showPassword = !showPassword)}
></button> ></button>
</div> </div>
{#if regCapsOn} {#if capsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div> <div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if} {/if}
</div> </div>
{#if !regEmailOnly}
<div class="auth-input-group">
<label class="auth-label" for="reg-confirm"
>{t('auth.confirm_password', 'Confirm password')}</label
>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-confirm"
class="auth-input"
data-testid="login-register-confirm-input"
type={regShowConfirm ? 'text' : 'password'}
bind:value={regConfirm}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowConfirm}
data-testid="login-register-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowConfirm = !regShowConfirm)}
></button>
</div>
{#if matchState}
<div
class="auth-match show {matchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
>
{matchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
{/if}
{/if} {/if}
<button
class="auth-button"
type="submit"
data-testid="login-register-submit-btn"
disabled={busy}
aria-busy={busy}
>
{!passwordLoginEnabled || regEmailOnly
? t('auth.register_email_only', 'Send me a sign-in link')
: t('auth.register', 'Create account')}
</button>
</form>
<div class="auth-toggle">
{t('auth.have_account', 'Already have an account?')}
<button
class="auth-toggle-link"
data-testid="login-register-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{:else}
<div class="setup-steps">
<div class="setup-step">
<div class="step-number active">1</div>
<div class="step-title active">{t('auth.setup_step1', 'Admin')}</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-title">{t('auth.setup_step2', 'System')}</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-title">{t('auth.setup_step3', 'Completed')}</div>
</div>
</div>
{#if setupError}<div class="auth-error" style="display: block" role="alert">
{setupError}
</div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
<form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="setup-username">
{t('auth.admin_username', 'Administrator username')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="setup-username"
class="auth-input"
data-testid="login-setup-username-input"
type="text"
value="admin"
readonly
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-email">
{t('auth.admin_email', 'Administrator email')}
</label>
<div class="auth-input-wrap auth-input-wrap--mail">
<input
id="setup-email"
class="auth-input"
data-testid="login-setup-email-input"
type="email"
bind:value={setupEmail}
bind:this={setupEmailInput}
autocomplete="email"
required
disabled={busy}
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-password">
{t('auth.admin_password', 'Administrator password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-password"
class="auth-input"
data-testid="login-setup-password-input"
type={setupShowPassword ? 'text' : 'password'}
bind:value={setupPassword}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
minlength="8"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowPassword}
data-testid="login-setup-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowPassword = !setupShowPassword)}
></button>
</div>
{#if setupCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-confirm">
{t('auth.confirm_password', 'Confirm password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-confirm"
class="auth-input"
data-testid="login-setup-confirm-input"
type={setupShowConfirm ? 'text' : 'password'}
bind:value={setupConfirm}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowConfirm}
data-testid="login-setup-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowConfirm = !setupShowConfirm)}
></button>
</div>
{#if setupMatchState}
<div
class="auth-match show {setupMatchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
>
{setupMatchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
<button <button
class="auth-button" class="auth-button"
type="submit" type="submit"
data-testid="login-setup-submit-btn" data-testid="login-submit-btn"
disabled={busy} disabled={busy}
aria-busy={busy} aria-busy={busy}
> >
{t('auth.create_admin', 'Create administrator')} {#if busy}
{submitAsMagicLink
? t('auth.sending', 'Sending…')
: t('auth.signing_in', 'Signing in…')}
{:else if submitAsMagicLink}
{t('auth.magicLinkSubmit', 'Send sign-in link')}
{:else}
{t('auth.sign_in', 'Sign in')}
{/if}
</button> </button>
</form> </form>
{/if}
{#if oidc.enabled}
{#if passwordLoginEnabled}
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
<a
class="auth-button auth-button-oidc"
data-testid="login-oidc-btn"
href={oidc.authorize_endpoint}
rel="external"
>
{t(
'auth.sso_login_provider',
{ provider: oidc.provider_name ?? 'SSO' },
'Sign in with {{provider}}'
)}
</a>
{/if}
{#if passwordLoginEnabled}
<div class="auth-toggle"> <div class="auth-toggle">
{t('auth.back_to_login', 'Already configured?')} {t('auth.no_account', 'No account?')}
<button <button
class="auth-toggle-link" class="auth-toggle-link"
data-testid="login-setup-to-login-btn" data-testid="login-to-register-btn"
onclick={() => (mode = 'login')} onclick={() => (mode = 'register')}
> >
{t('auth.sign_in', 'Sign in')} {t('auth.register', 'Create one')}
</button> </button>
</div> </div>
{/if} {/if}
{#if setupAvailable}
<div class="auth-toggle">
{t('auth.admin_setup', 'First time?')}
<button
class="auth-toggle-link"
data-testid="login-to-setup-btn"
onclick={() => (mode = 'setup')}
>
{t('auth.setup', 'Set up administrator')}
</button>
</div>
{/if}
{:else if mode === 'register'}
{#if regError}<div class="auth-error" style="display: block" role="alert">
{regError}
</div>{/if}
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
<!-- Email is the only required identifier since PR 18 — the
backend accepts email-only signup and mints a welcome
magic-link. Username is optional at this stage; the user
can claim a handle later via profile settings. -->
<div class="auth-input-group">
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label>
<input
id="reg-email"
class="auth-input"
data-testid="login-register-email-input"
type="email"
bind:value={regEmail}
bind:this={registerEmailInput}
autocomplete="email"
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-username">
{t('auth.username_optional', 'Username (optional)')}
</label>
<input
id="reg-username"
class="auth-input"
data-testid="login-register-username-input"
bind:value={regUsername}
autocomplete="username"
disabled={busy}
/>
</div>
<!-- Password fields hide entirely when policy forbids password
login — the whole form becomes email-only in that mode. -->
{#if passwordLoginEnabled}
<div class="auth-input-group">
<label class="auth-label" for="reg-password">
{t('auth.password_optional', 'Password (optional — leave blank for a sign-in link)')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-password"
class="auth-input"
data-testid="login-register-password-input"
type={regShowPassword ? 'text' : 'password'}
bind:value={regPassword}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowPassword}
data-testid="login-register-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)}
></button>
</div>
{#if regCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
{#if !regEmailOnly}
<div class="auth-input-group">
<label class="auth-label" for="reg-confirm"
>{t('auth.confirm_password', 'Confirm password')}</label
>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-confirm"
class="auth-input"
data-testid="login-register-confirm-input"
type={regShowConfirm ? 'text' : 'password'}
bind:value={regConfirm}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowConfirm}
data-testid="login-register-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowConfirm = !regShowConfirm)}
></button>
</div>
{#if matchState}
<div
class="auth-match show {matchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
>
{matchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
{/if}
{/if}
<button
class="auth-button"
type="submit"
data-testid="login-register-submit-btn"
disabled={busy}
aria-busy={busy}
>
{!passwordLoginEnabled || regEmailOnly
? t('auth.register_email_only', 'Send me a sign-in link')
: t('auth.register', 'Create account')}
</button>
</form>
<div class="auth-toggle">
{t('auth.have_account', 'Already have an account?')}
<button
class="auth-toggle-link"
data-testid="login-register-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{:else}
<div class="setup-steps">
<div class="setup-step">
<div class="step-number active">1</div>
<div class="step-title active">{t('auth.setup_step1', 'Admin')}</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-title">{t('auth.setup_step2', 'System')}</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-title">{t('auth.setup_step3', 'Completed')}</div>
</div>
</div>
{#if setupError}<div class="auth-error" style="display: block" role="alert">
{setupError}
</div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
<form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="setup-username">
{t('auth.admin_username', 'Administrator username')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="setup-username"
class="auth-input"
data-testid="login-setup-username-input"
type="text"
value="admin"
readonly
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-email">
{t('auth.admin_email', 'Administrator email')}
</label>
<div class="auth-input-wrap auth-input-wrap--mail">
<input
id="setup-email"
class="auth-input"
data-testid="login-setup-email-input"
type="email"
bind:value={setupEmail}
bind:this={setupEmailInput}
autocomplete="email"
required
disabled={busy}
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-password">
{t('auth.admin_password', 'Administrator password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-password"
class="auth-input"
data-testid="login-setup-password-input"
type={setupShowPassword ? 'text' : 'password'}
bind:value={setupPassword}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
minlength="8"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowPassword}
data-testid="login-setup-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowPassword = !setupShowPassword)}
></button>
</div>
{#if setupCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-confirm">
{t('auth.confirm_password', 'Confirm password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-confirm"
class="auth-input"
data-testid="login-setup-confirm-input"
type={setupShowConfirm ? 'text' : 'password'}
bind:value={setupConfirm}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowConfirm}
data-testid="login-setup-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowConfirm = !setupShowConfirm)}
></button>
</div>
{#if setupMatchState}
<div
class="auth-match show {setupMatchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
>
{setupMatchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
<button
class="auth-button"
type="submit"
data-testid="login-setup-submit-btn"
disabled={busy}
aria-busy={busy}
>
{t('auth.create_admin', 'Create administrator')}
</button>
</form>
<div class="auth-toggle">
{t('auth.back_to_login', 'Already configured?')}
<button
class="auth-toggle-link"
data-testid="login-setup-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{/if}
<div class="auth-lang"> <div class="auth-lang">
<select <select
aria-label={t('settings.language', 'Language')} aria-label={t('settings.language', 'Language')}
+18 -5
View File
@@ -41,7 +41,15 @@ beforeEach(() => {
pageState.url = new URL('http://localhost/login'); pageState.url = new URL('http://localhost/login');
session.user = null; session.user = null;
m(auth.fetchMe).mockResolvedValue(null); m(auth.fetchMe).mockResolvedValue(null);
m(auth.getOidcProviders).mockResolvedValue({ providers: [] }); // Default provider info: both password + magic-link enabled, OIDC off.
// The unified login form's magic-link submit path is only reachable
// when `magic_link_login_enabled === true` — without this pin the
// "sends a magic link" test can't reach `sendMagicLink()`.
m(auth.getOidcProviders).mockResolvedValue({
enabled: false,
password_login_enabled: true,
magic_link_login_enabled: true
});
m(auth.getAuthStatus).mockResolvedValue({ initialized: true }); m(auth.getAuthStatus).mockResolvedValue({ initialized: true });
}); });
@@ -75,16 +83,21 @@ it('enters setup mode on a fresh install', async () => {
await screen.findByTestId('login-setup-form'); await screen.findByTestId('login-setup-form');
}); });
it('sends a magic link', async () => { it('sends a magic link when the password field is left empty', async () => {
// Unified form: the same identifier input drives both flows. Filling
// the identifier and leaving password empty makes `submitAsMagicLink`
// derived resolve to true — the single submit button then dispatches
// to `sendMagicLink` instead of `login`.
m(auth.sendMagicLink).mockResolvedValue('sent'); m(auth.sendMagicLink).mockResolvedValue('sent');
render(LoginPage); render(LoginPage);
await screen.findByTestId('login-form'); await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn')); await fireEvent.input(screen.getByTestId('login-username-input'), {
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
target: { value: 'a@b.test' } target: { value: 'a@b.test' }
}); });
await fireEvent.click(screen.getByTestId('login-magic-send-btn')); // Password intentionally NOT filled.
await fireEvent.click(screen.getByTestId('login-submit-btn'));
await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test')); await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test'));
expect(auth.login).not.toHaveBeenCalled();
}); });
it('registers a new account', async () => { it('registers a new account', async () => {
+15 -4
View File
@@ -29,11 +29,22 @@ test.describe('SPA · authentication', () => {
await expect(page.getByTestId('login-form')).toBeVisible(); await expect(page.getByTestId('login-form')).toBeVisible();
}); });
test('magic-link panel toggles open', async ({ page }) => { test('submit button dispatches to magic-link when password is empty', async ({ page }) => {
// Unified login form: one identifier + one optional password + one
// adaptive submit button. Filling the identifier and leaving the
// password blank flips the button label to "Send sign-in link" and
// routes to /api/auth/magic-link/send on click. The old two-form
// UX with `login-magic-toggle-btn` was retired 2026-07-14.
await page.goto('/login'); await page.goto('/login');
await page.getByTestId('login-magic-toggle-btn').click(); await expect(page.getByTestId('login-form')).toBeVisible();
await expect(page.getByTestId('login-magic-form')).toBeVisible(); await page.getByTestId('login-username-input').fill('someone@example.test');
await expect(page.getByTestId('login-magic-email-input')).toBeVisible(); // Password intentionally NOT filled — this drives the label swap.
const submit = page.getByTestId('login-submit-btn');
await expect(submit).toBeVisible();
// Label content differs per mode: password-empty → magic-link copy;
// password-filled → "Sign in". Assert the magic-link copy is what's
// shown so the dispatch is provably in the magic-link branch.
await expect(submit).toHaveText(/link|Link|Send/);
}); });
test('successful login reaches the files app shell', async ({ page }) => { test('successful login reaches the files app shell', async ({ page }) => {
+14 -6
View File
@@ -58,13 +58,21 @@ test('an oidc callback code is exchanged on load', async ({ page }) => {
}); });
test('request a magic link from the login page', async ({ page }) => { test('request a magic link from the login page', async ({ page }) => {
// Unified form: leave the password field empty and submit — the
// adaptive submit routes to /api/auth/magic-link/send with the
// identifier as-is (backend accepts email OR username via `@`
// dispatch). Old separate `login-magic-*` testids retired in the
// single-form refactor.
await page.goto('/login'); await page.goto('/login');
await page.getByTestId('login-magic-toggle-btn').click(); await expect(page.getByTestId('login-form')).toBeVisible();
await expect(page.getByTestId('login-magic-form')).toBeVisible();
await page.getByTestId('login-magic-email-input').fill('someone@example.test'); await page.getByTestId('login-username-input').fill('someone@example.test');
await page.getByTestId('login-magic-send-btn').click(); // Password intentionally NOT filled.
// A status message resolves (success or error); give the request time to run. await page.getByTestId('login-submit-btn').click();
// A status message resolves (uniform 200 anti-enum success or error);
// give the request time to run.
await page.waitForTimeout(1_000); await page.waitForTimeout(1_000);
await expect(page.getByTestId('login-magic-form').or(page.getByTestId('login-form')).first()).toBeVisible(); // Still on the login page either way — anti-enum success doesn't redirect.
await expect(page.getByTestId('login-form')).toBeVisible();
}); });