test(frontend): correct test to fit sign up/in
This commit is contained in:
@@ -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')}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user