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 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')}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user