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>
<!-- Form paints immediately alongside the logo — the onMount
<!-- Form paints immediately alongside the logo — the onMount
probes (OIDC code exchange, session probe, providers
lookup) run concurrently and either redirect the user
away or upgrade the visible affordances (OIDC button,
magic-link toggle) in place. Guarding the whole form
behind `booting` caused a "logo only, then form" flash
on first paint. -->
<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}
<h1 class="auth-title">
{#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 filled → POST /api/auth/login
* password empty → POST /api/auth/magic-link/send
* password-only server → password field is required, no hint
* magic-link-only server → password field hides entirely -->
{#if passwordLoginEnabled || magicLinkLoginEnabled}
{#if error}
<div
class={emailNotVerified ? 'auth-success' : 'auth-error'}
style="display: block"
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"
{#if passwordLoginEnabled || magicLinkLoginEnabled}
{#if error}
<div
class={emailNotVerified ? 'auth-success' : 'auth-error'}
style="display: block"
role="alert"
>
{t(
'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>
{error}
</div>
{/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>
{#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}
{: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. -->
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
<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 class="auth-label" for="login-username">
{t('auth.login_identifier', 'Username or email')}
</label>
<input
id="reg-username"
class="auth-input"
data-testid="login-register-username-input"
bind:value={regUsername}
autocomplete="username"
disabled={busy}
/>
<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>
<!-- 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 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="reg-password"
id="login-password"
class="auth-input"
data-testid="login-register-password-input"
type={regShowPassword ? 'text' : 'password'}
bind:value={regPassword}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
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={regShowPassword}
data-testid="login-register-password-toggle-btn"
aria-pressed={showPassword}
data-testid="login-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)}
onclick={() => (showPassword = !showPassword)}
></button>
</div>
{#if regCapsOn}
{#if capsOn}
<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"
data-testid="login-submit-btn"
disabled={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>
</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">
{t('auth.back_to_login', 'Already configured?')}
{t('auth.no_account', 'No account?')}
<button
class="auth-toggle-link"
data-testid="login-setup-to-login-btn"
onclick={() => (mode = 'login')}
data-testid="login-to-register-btn"
onclick={() => (mode = 'register')}
>
{t('auth.sign_in', 'Sign in')}
{t('auth.register', 'Create one')}
</button>
</div>
{/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">
<select
aria-label={t('settings.language', 'Language')}
+18 -5
View File
@@ -41,7 +41,15 @@ beforeEach(() => {
pageState.url = new URL('http://localhost/login');
session.user = 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 });
});
@@ -75,16 +83,21 @@ it('enters setup mode on a fresh install', async () => {
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');
render(LoginPage);
await screen.findByTestId('login-form');
await fireEvent.click(screen.getByTestId('login-magic-toggle-btn'));
await fireEvent.input(screen.getByTestId('login-magic-email-input'), {
await fireEvent.input(screen.getByTestId('login-username-input'), {
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'));
expect(auth.login).not.toHaveBeenCalled();
});
it('registers a new account', async () => {