Files
windmill/frontend/src/lib/components/Password.svelte
T
28b2ca6367 feat: inline login errors and a narrower single-column login card (#10777)
* feat: inline login errors and a narrower single-column login card

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address login review findings (overflow, error leak, a11y, dev gate)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope login form ids per instance

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop the duplicate dark mode toggle and tighten the login heading gap

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: replay the login shake on every retry, not just the first

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address standards and spec review findings on the login page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: put the login error under the field it is about

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: attribute a login failure to the credentials it was sent with

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: hide the third-party toggle once the password form is open

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: brand the logged-out pages from one top header instead of a centered logo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: remember the login method that last worked on this browser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: lead the login card with the last used method and anchor its layout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address review round findings on the login card

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: key third-party buttons by method kind and drop a history comment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-08-21 10:40:34 +02:00

158 lines
4.4 KiB
Svelte

<script lang="ts">
import { createBubbler } from 'svelte/legacy'
import { tick } from 'svelte'
import Button from './common/button/Button.svelte'
import TextInput from './text_input/TextInput.svelte'
import { Eye, EyeClosed } from 'lucide-svelte'
import type { HTMLInputAttributes } from 'svelte/elements'
const bubble = createBubbler()
interface Props {
password: string | undefined
placeholder?: string
disabled?: boolean
required?: boolean
small?: boolean
minRows?: number
id?: string
autocomplete?: HTMLInputAttributes['autocomplete']
/** Off for login-style fields: keeps Enter free to submit. Overrides `minRows`. */
allowMultiline?: boolean
/** Renders the field in its error state; the message itself is the caller's to display. */
error?: boolean
/** id of the element holding that message, wired up as aria-describedby. */
describedBy?: string
onKeyDown?: (event: KeyboardEvent) => void
onBlur?: (event: FocusEvent) => void
}
let {
password = $bindable(),
placeholder = '******',
disabled = false,
required = false,
small = false,
minRows,
id,
autocomplete = 'new-password',
allowMultiline = true,
error = false,
describedBy = undefined,
onKeyDown,
onBlur
}: Props = $props()
let red = $derived(required && (password == '' || password == undefined))
let hasError = $derived(red || error)
let hideValue = $state(true)
let forceMultiline = $state(false)
let isMultiline = $derived(
allowMultiline &&
(forceMultiline || (minRows != null && minRows > 1) || (password?.includes('\n') ?? false))
)
let textareaRef: TextInput<'textarea'> | undefined = $state()
let inputRef: TextInput<'input'> | undefined = $state()
export function focus() {
;(isMultiline ? textareaRef : inputRef)?.focus()
}
// Revealing swaps the input to type="text". Auth forms conceal again before submitting,
// so the browser sees a password field when it decides whether to save the credential.
export function conceal() {
hideValue = true
}
function insertAndSwitchToMultiline(input: HTMLInputElement, text: string) {
const start = input.selectionStart
const end = input.selectionEnd
if (start != null && end != null) {
password = (password ?? '').substring(0, start) + text + (password ?? '').substring(end)
} else {
// selectionStart/End are null for type="password" inputs
password = (password ?? '') + text
}
forceMultiline = true
tick().then(() => textareaRef?.focus())
}
</script>
<div class="relative w-full {small ? 'max-w-lg' : ''}">
{#if isMultiline}
<TextInput
bind:this={textareaRef}
size="md"
error={hasError}
bind:value={password}
underlyingInputEl="textarea"
inputProps={{
id,
disabled,
placeholder,
rows: minRows ?? 3,
autocomplete,
'aria-invalid': hasError ? 'true' : undefined,
'aria-describedby': describedBy,
onblur: (e) => onBlur?.(e),
onkeydown: (e) => {
onKeyDown?.(e)
bubble('keydown')(e)
},
style: hideValue ? '-webkit-text-security: disc' : ''
}}
class="pr-8"
unifiedHeight={false}
/>
{:else}
<TextInput
bind:this={inputRef}
size="md"
error={hasError}
bind:value={password}
inputProps={{
id,
disabled,
placeholder,
autocomplete,
'aria-invalid': hasError ? 'true' : undefined,
'aria-describedby': describedBy,
onblur: (e) => onBlur?.(e),
onkeydown: (e) => {
if (allowMultiline && e.key === 'Enter') {
e.preventDefault()
insertAndSwitchToMultiline(e.currentTarget as HTMLInputElement, '\n')
return
}
onKeyDown?.(e)
bubble('keydown')(e)
},
onpaste: (e) => {
const text = e.clipboardData?.getData('text')
if (allowMultiline && text?.includes('\n')) {
e.preventDefault()
insertAndSwitchToMultiline(e.currentTarget as HTMLInputElement, text)
}
},
type: hideValue ? 'password' : 'text'
}}
class="pr-8"
/>
{/if}
<!-- After the input in DOM order so Tab reaches the field before the toggle -->
<div class="absolute {isMultiline ? 'top-1' : 'inset-y-0'} right-1 flex items-center z-10">
<Button
unifiedSize="sm"
onClick={() => (hideValue = !hideValue)}
iconOnly
startIcon={{ icon: hideValue ? Eye : EyeClosed }}
variant="subtle"
title={hideValue ? 'Show password' : 'Hide password'}
wrapperClasses="bg-surface-input"
/>
</div>
</div>
{#if red}
<div class="text-red-600 text-2xs grow">This field is required</div>
{/if}