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>
This commit is contained in:
Guilhem
2026-08-21 10:40:34 +02:00
committed by GitHub
parent 92a454b7a8
commit 28b2ca6367
26 changed files with 866 additions and 320 deletions
@@ -1,14 +1,12 @@
<script lang="ts">
import { setLicense } from '$lib/enterpriseUtils'
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import WindmillIcon from './icons/WindmillIcon.svelte'
import { Loader2 } from 'lucide-svelte'
import LoginPageHeader from './LoginPageHeader.svelte'
interface Props {
subtitle?: string | undefined
title?: string
disableLogo?: boolean
large?: boolean
centerVertically?: boolean
loading?: boolean
@@ -19,7 +17,6 @@
let {
subtitle = undefined,
title = 'Windmill',
disableLogo = false,
large = false,
centerVertically = true,
loading = false,
@@ -47,19 +44,16 @@
containOverflow ? '' : height > 1080 ? 'pt-28' : 'pt-12'
)}
>
{#if (!disableLogo && !$enterpriseLicense) || !$whitelabelNameStore}
<div class="hidden lg:block">
<div>
<WindmillIcon size={centerVertically ? 64 : 48} spin={loading ? 'fast' : 'slow'} />
</div>
</div>
{:else}
<div class="pt-8"></div>
{/if}
<div class="mb-4">
<h1 class="text-center text-lg text-emphasis font-semibold">
<!-- The mark moved to the header, so a page that is only waiting (logging out,
redirecting) needs its own thing that moves. -->
<h1
class="flex items-center justify-center gap-2 text-center text-lg text-emphasis font-semibold"
>
{title}
{#if loading}
<Loader2 size={16} class="animate-spin shrink-0 text-secondary" />
{/if}
</h1>
{#if subtitle}
<p class="text-xs font-normal text-primary text-center mt-2">
+317 -94
View File
@@ -1,17 +1,35 @@
<script module lang="ts">
import type { LastLoginMethod } from '$lib/lastLoginMethod'
/** Feeds the login card a fixed instance configuration instead of the live one.
* Only the kitchen sink at /kitchen_sink/login sets it; production always fetches. */
export type LoginPreview = {
logins?: { type: string; displayName: string }[]
saml?: boolean
disablePasswordLogin?: boolean
smtpConfigured?: boolean
cloud?: boolean
autoRedirecting?: boolean
lastUsed?: LastLoginMethod
}
</script>
<script lang="ts">
import { goto } from '$lib/navigation'
import Github from '$lib/components/icons/brands/Github.svelte'
import Gitlab from '$lib/components/icons/brands/Gitlab.svelte'
import Google from '$lib/components/icons/brands/Google.svelte'
import Microsoft from '$lib/components/icons/brands/Microsoft.svelte'
import Okta from '$lib/components/icons/brands/Okta.svelte'
import Auth0 from '$lib/components/icons/brands/Auth0.svelte'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
import {
Auth0Icon,
GithubIcon,
GitlabIcon,
GoogleIcon,
MicrosoftIcon,
NextcloudIcon,
OktaIcon
} from '$lib/components/icons'
import PocketIdIcon from '$lib/components/icons/PocketIdIcon.svelte'
import { OauthService, UserService, WorkspaceService } from '$lib/gen'
import { usersWorkspaceStore, workspaceStore, userStore } from '$lib/stores'
import { classNames, emptyString, escapeHtml, parseQueryParams } from '$lib/utils'
import { emptyString, escapeHtml, parseQueryParams } from '$lib/utils'
import { base } from '$lib/base'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
@@ -24,6 +42,17 @@
import TextInput from './text_input/TextInput.svelte'
import { sameTopDomainOrigin } from '$lib/cookies'
import { isValidLogoutRedirect, toSameOriginRelativePath } from '$lib/logoutRedirect'
import InputError from './InputError.svelte'
import { loginErrorMessage } from '$lib/loginError'
import Badge from './common/badge/Badge.svelte'
import {
getLastLoginMethod,
clearPendingLoginMethod,
confirmPendingLoginMethod,
markLoginMethodPending,
rememberLoginMethod,
sameLoginMethod
} from '$lib/lastLoginMethod'
interface Props {
rd?: string | undefined
@@ -34,6 +63,10 @@
firstTime?: boolean
autoRedirect?: boolean
onLoginSuccess?: () => void
preview?: LoginPreview
/** Reports the instance's login options once loaded, so the page around the card can
* adapt its heading: a third-party login also creates the account on first use. */
onOptionsLoaded?: (options: { hasThirdParty: boolean }) => void
}
let {
@@ -44,39 +77,56 @@
popup = false,
firstTime = false,
autoRedirect = true,
onLoginSuccess = undefined
onLoginSuccess = undefined,
preview = undefined,
onOptionsLoaded = undefined
}: Props = $props()
// The harness never takes effect in a production bundle, whatever a caller passes.
let previewConfig = $derived(import.meta.env.DEV ? preview : undefined)
let cloudHosted = $derived(previewConfig ? !!previewConfig.cloud : isCloudHosted())
let lastUsed = $state<LastLoginMethod | undefined>(undefined)
let lastUsedPassword = $derived(!!lastUsed && lastUsed.kind === 'password')
// Scoped per instance: the kitchen sink mounts every card at once, and a hardcoded id
// would point each card's labels and aria-describedby at the first card's fields.
const uid = $props.id()
const emailId = `${uid}-email`
const passwordId = `${uid}-password`
const errorId = `${uid}-error`
const emailErrorId = `${uid}-email-error`
const providers = [
{
type: 'github',
name: 'GitHub',
icon: Github
icon: GithubIcon
},
{
type: 'gitlab',
name: 'GitLab',
icon: Gitlab
icon: GitlabIcon
},
{
type: 'google',
name: 'Google',
icon: Google
icon: GoogleIcon
},
{
type: 'microsoft',
name: 'Microsoft',
icon: Microsoft
icon: MicrosoftIcon
},
{
type: 'okta',
name: 'Okta',
icon: Okta
icon: OktaIcon
},
{
type: 'auth0',
name: 'Auth0',
icon: Auth0
icon: Auth0Icon
},
{
type: 'nextcloud',
@@ -90,7 +140,32 @@
}
] as const
const providersType = providers.map((p) => p.type as string)
type ThirdPartyMethod = {
method: { kind: 'oauth'; provider: string } | { kind: 'saml' }
displayName: string
icon?: any
}
// rank() maps an unknown type to known.length, not indexOf's -1, so a custom OAuth client
// sorts after the known providers rather than ahead of all of them. SAML sits at the end,
// and whatever worked last time is hoisted to the front.
let orderedThirdParty = $derived.by(() => {
const known = providers.map((p) => p.type as string)
const rank = (type: string) => (known.indexOf(type) === -1 ? known.length : known.indexOf(type))
const oauth: ThirdPartyMethod[] = [...(logins ?? [])]
.sort((a, b) => rank(a.type) - rank(b.type))
.map((login) => ({
method: { kind: 'oauth', provider: login.type },
displayName: login.displayName,
icon: providers.find((p) => p.type === login.type)?.icon
}))
const all: ThirdPartyMethod[] = saml
? [...oauth, { method: { kind: 'saml' }, displayName: 'SSO' }]
: oauth
const lastIdx = all.findIndex((m) => sameLoginMethod(lastUsed, m.method))
if (lastIdx > 0) all.unshift(...all.splice(lastIdx, 1))
return all
})
let showPassword = $state(false)
let passwordField = $state<Password | undefined>(undefined)
@@ -103,6 +178,54 @@
let autoRedirecting = $state(false)
let oauthFlowDone = false
// The method that worked last time leads: the form takes the top of the card, already open.
let passwordFirst = $derived(lastUsedPassword && !disablePasswordLogin && !autoRedirecting)
// Errors that belong to the credentials the user just submitted: they stay under the
// password field until either field changes, so a stale message can't outlive its attempt.
let formError = $state<
| {
message: string
fields: 'both' | 'email' | 'password'
email: string | undefined
password: string | undefined
}
| undefined
>(undefined)
let shake = $state(false)
let fieldsEl = $state<HTMLDivElement | undefined>(undefined)
let credentialsError = $derived(
formError && formError.email === email && formError.password === password
? formError.message
: undefined
)
// 'both' sits under the password field, at the end of the form, where a rejected
// credential pair belongs; a single missing field gets the message under itself.
let errorField = $derived(credentialsError ? (formError?.fields ?? 'both') : undefined)
let emailErrored = $derived(errorField === 'email' || errorField === 'both')
let passwordErrored = $derived(errorField === 'password' || errorField === 'both')
async function failLogin(
message: string,
fields: 'both' | 'email' | 'password' = 'both',
// The pair the message is about. Defaults to what is in the fields right now, but a
// rejected request passes what it actually submitted: the user may have typed on since.
attempted: { email: string | undefined; password: string | undefined } = { email, password }
) {
// The shake is for a retry that fails the same way: on the first failure the message
// appearing is the signal, and shaking it in would be noise.
const wasAlreadyShown = credentialsError != undefined
formError = { message, fields, ...attempted }
// tick() only writes the DOM. Without a layout read between the removal and the re-add,
// the browser coalesces both into one style recalculation, sees a class that never left,
// and replays nothing from the second retry onwards.
shake = false
if (!wasAlreadyShown) return
await tick()
void fieldsEl?.offsetWidth
shake = true
}
type OAuthLogin = {
type: string
displayName: string
@@ -110,7 +233,14 @@
async function login(): Promise<void> {
if (!email || !password) {
sendUserToast('Please fill in both email and password', true)
if (!email && !password) failLogin('Enter both your email and password.')
else if (!email) failLogin('Enter your email.', 'email')
else failLogin('Enter your password.', 'password')
return
}
if (previewConfig) {
failLogin('Invalid email or password.')
return
}
@@ -127,10 +257,13 @@
try {
await UserService.login({ requestBody })
} catch (err) {
sendUserToast('Invalid credentials', true)
failLogin(loginErrorMessage(err), 'both', requestBody)
return
}
formError = undefined
rememberLoginMethod({ kind: 'password' })
if (firstTime) {
goto('/user/first-time')
return
@@ -207,6 +340,21 @@
}
async function loadLogins() {
if (previewConfig) {
logins = previewConfig.logins ?? []
saml = previewConfig.saml ? 'https://idp.example.com/sso' : undefined
disablePasswordLogin = previewConfig.disablePasswordLogin ?? false
autoRedirecting = previewConfig.autoRedirecting ?? false
lastUsed = previewConfig.lastUsed
showPassword =
!disablePasswordLogin &&
(lastUsedPassword ||
(logins.length === 0 && !saml) ||
(email != undefined && email.length > 0))
onOptionsLoaded?.({ hasThirdParty: logins.length > 0 || !!saml })
return
}
const [loginsResult, disabledResult] = await Promise.allSettled([
OauthService.listOauthLogins(),
UserService.isPasswordLoginDisabled()
@@ -233,9 +381,14 @@
console.error('Could not load logins', loginsResult.reason)
}
lastUsed = getLastLoginMethod()
showPassword =
!disablePasswordLogin &&
((logins?.length === 0 && !saml) || (email != undefined && email.length > 0))
(lastUsedPassword ||
(logins?.length === 0 && !saml) ||
(email != undefined && email.length > 0))
onOptionsLoaded?.({ hasThirdParty: (logins?.length ?? 0) > 0 || !!saml })
if (autoRedirect && autoLogin && !error && !shouldSkipAutoRedirect()) {
if (autoLogin === 'saml' && saml) {
@@ -270,6 +423,10 @@
})
async function checkSmtpConfigured() {
if (previewConfig) {
smtpConfigured = previewConfig.smtpConfigured ?? false
return
}
try {
smtpConfigured = await UserService.isSmtpConfigured()
} catch (err) {
@@ -346,6 +503,7 @@
function finishOauthFlow(via: 'postMessage' | 'storage' | 'poll', win?: Window) {
if (oauthFlowDone) return
oauthFlowDone = true
confirmPendingLoginMethod()
console.log(`oauth: signaled via ${via}`)
if (win && !win.closed) win.close()
window.removeEventListener('message', popupListener)
@@ -373,6 +531,9 @@
}
function storeRedirect(provider: string): boolean {
// The kitchen sink renders real provider buttons; clicking one must not leave the page.
if (previewConfig) return true
markLoginMethodPending({ kind: 'oauth', provider })
persistRd()
let url = base + '/api/oauth/login/' + provider + (popup ? '?close=true' : '')
console.log('storeRedirect', popup, url)
@@ -385,6 +546,7 @@
if (!win) {
window.removeEventListener('message', popupListener)
window.removeEventListener('storage', handleStorageEvent)
clearPendingLoginMethod()
return false
}
// Safety net for Safari: when the popup is opened without a fresh user
@@ -434,6 +596,8 @@
sendUserToast('No SAML login available', true)
return false
}
if (previewConfig) return true
markLoginMethodPending({ kind: 'saml' })
let target = saml
let relayStateSet = false
// Carry the SP-initiated deep link through the IdP round-trip via SAML
@@ -467,55 +631,91 @@
$effect(() => {
error && sendUserToast(escapeHtml(error), true)
})
let loginOptionCount = $derived((logins?.length ?? 0) + (saml ? 1 : 0))
</script>
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if autoRedirecting}
<p class="text-sm text-center text-secondary py-4">Signing you in…</p>
{/if}
<div
class="grid {loginOptionCount > 3 ? 'grid-cols-2' : ''} gap-4 {autoRedirecting ? 'hidden' : ''}"
>
<!-- The red borders are colour-only, so role="alert" is what makes a failed attempt reach a
screen reader. -->
{#snippet errorMessage()}
<div id={errorId} role="alert" class="min-h-5">
{#if errorField !== 'email'}
<InputError error={credentialsError} />
{/if}
</div>
{/snippet}
<!-- Straddles the button's top edge, so the row keeps its height and the badge reads as a
label on the button rather than another line of content. -->
{#snippet lastUsedBadge()}
<!-- Hung off the corner like a notification badge: a long provider name wraps to two lines
inside the button, and anything sitting further in would land on the label. The ring is
the card's own colour so the badge punches through the button border. -->
<div class="absolute top-0 right-0 -translate-y-1/2 translate-x-1/4 z-10">
<Badge color="blue" small class="ring-2 ring-surface !px-1 !py-0 !text-[10px] leading-4">
Last used
</Badge>
</div>
{/snippet}
{#snippet providerButtons()}
<div class="grid gap-4 {autoRedirecting ? 'hidden' : ''}">
{#if !logins}
{#each Array(4) as _}
<Skeleton layout={[0.5, [2.375]]} />
{/each}
{:else}
{#each providers as { type, icon }}
{#if logins?.some((login) => login.type === type)}
{#each orderedThirdParty as entry (entry.method.kind === 'saml' ? 'saml:' : `oauth:${entry.method.provider}`)}
<div class="relative">
{#if sameLoginMethod(lastUsed, entry.method)}
{@render lastUsedBadge()}
{/if}
<Button
variant="default"
startIcon={{ icon, classes: 'h-4' }}
on:click={() => storeRedirect(type)}
unifiedSize="lg"
startIcon={entry.icon ? { icon: entry.icon, classes: 'h-4' } : undefined}
onClick={() =>
entry.method.kind === 'saml' ? redirectSaml() : storeRedirect(entry.method.provider)}
>
{logins.find((login) => login.type === type)?.displayName}
Continue with {entry.displayName}
</Button>
{/if}
</div>
{/each}
{#each logins.filter((login) => !providersType?.includes(login.type)) as login}
<Button variant="default" on:click={() => storeRedirect(login.type)}>
{login.displayName}
</Button>
{/each}
{/if}
{#if saml}
<Button variant="default" on:click={redirectSaml}>SSO</Button>
{/if}
</div>
{#if !autoRedirecting && !disablePasswordLogin && (saml || (logins && logins.length > 0))}
<div class={classNames('center-center', logins && logins.length > 0 ? 'mt-6' : '')}>
<Button
size="xs"
variant="subtle"
on:click={() => {
showPassword = !showPassword
}}
>
Log in without third-party
</Button>
</div>
{/snippet}
{#snippet orDivider()}
<div class="flex items-center gap-3 my-6">
<div class="h-px flex-1 bg-border-light"></div>
<span class="text-2xs uppercase text-secondary">or</span>
<div class="h-px flex-1 bg-border-light"></div>
</div>
{/snippet}
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if autoRedirecting}
<p class="text-sm text-center text-secondary py-4">Signing you in…</p>
{/if}
{#if !passwordFirst}
{@render providerButtons()}
{#if !autoRedirecting && !disablePasswordLogin && (saml || (logins && logins.length > 0))}
{@render orDivider()}
<!-- Only an entry point to the form below: once that is open the divider is what
separates the two ways in. -->
{#if !showPassword}
<div class="center-center">
<Button
unifiedSize="sm"
variant="subtle"
onClick={() => {
showPassword = true
}}
>
Log in without third-party
</Button>
</div>
{/if}
{/if}
{/if}
{#if !autoRedirecting && showPassword && !disablePasswordLogin}
@@ -525,51 +725,73 @@
Welcome! Default credentials admin@windmill.dev / changeme have been prefilled.
</p>
{/if}
<div class="space-y-6">
{#if isCloudHosted()}
<div class="space-y-4">
{#if cloudHosted}
<p class="text-xs text-secondary pb-6">
To get credentials without the OAuth providers above, send an email at
contact@windmill.dev
</p>
{/if}
<div class="space-y-1">
<label for="email" class="block text-xs font-semibold text-emphasis"> Email </label>
<div>
<TextInput
size="md"
bind:value={email}
inputProps={{
id: 'email',
type: 'email',
autocomplete: 'username',
onkeydown: (e) => {
// Only move on once the field holds something: while the browser's
// credential dropdown is open, Enter belongs to the dropdown
if (e.key === 'Enter' && !e.isComposing && !e.repeat && e.currentTarget.value) {
e.preventDefault()
passwordField?.focus()
<div bind:this={fieldsEl} class="space-y-6 {shake ? 'motion-safe:animate-shake' : ''}">
<div class="space-y-1">
<label for={emailId} class="block text-xs font-semibold text-emphasis"> Email </label>
<div>
<TextInput
size="md"
error={emailErrored}
bind:value={email}
inputProps={{
id: emailId,
type: 'email',
autocomplete: 'username',
'aria-invalid': emailErrored ? 'true' : undefined,
'aria-describedby':
errorField === 'email' ? emailErrorId : emailErrored ? errorId : undefined,
onkeydown: (e) => {
// Only move on once the field holds something: while the browser's
// credential dropdown is open, Enter belongs to the dropdown
if (e.key === 'Enter' && !e.isComposing && !e.repeat && e.currentTarget.value) {
e.preventDefault()
passwordField?.focus()
}
}
}
}}
/>
}}
/>
</div>
<div id={emailErrorId} role="alert" class="min-h-5">
{#if errorField === 'email'}
<InputError error={credentialsError} />
{/if}
</div>
</div>
<div class="space-y-1">
<label for={passwordId} class="block text-xs font-semibold text-emphasis">
Password
</label>
<div>
<Password
bind:this={passwordField}
bind:password
id={passwordId}
placeholder=""
autocomplete="current-password"
allowMultiline={false}
error={passwordErrored}
describedBy={passwordErrored ? errorId : undefined}
onKeyDown={handleKeyDown}
/>
</div>
{@render errorMessage()}
</div>
</div>
<div class="space-y-1">
<label for="password" class="block text-xs font-semibold text-emphasis"> Password </label>
<div>
<Password
bind:this={passwordField}
bind:password
id="password"
placeholder=""
autocomplete="current-password"
allowMultiline={false}
onKeyDown={handleKeyDown}
/>
</div>
<div>
<Button onClick={login} variant="accent" unifiedSize="lg" disabled={!email || !password}>
Log in
</Button>
{#if smtpConfigured}
<div class="text-right pt-1">
<div class="text-center pt-2">
<a
href="{base}/user/forgot-password"
class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
@@ -579,13 +801,9 @@
</div>
{/if}
</div>
<div class="pt-2">
<Button onClick={login} variant="accent" disabled={!email || !password}>Sign in</Button>
</div>
</div>
{#if isCloudHosted()}
{#if cloudHosted}
<p class="text-2xs text-secondary mt-10 text-center">
By logging in, you agree to our
<a href="https://windmill.dev/terms_of_service" target="_blank" rel="noreferrer">
@@ -599,4 +817,9 @@
{/if}
</div>
{/if}
{#if passwordFirst && (saml || (logins && logins.length > 0))}
{@render orDivider()}
{@render providerButtons()}
{/if}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import { whitelabelNameStore } from '$lib/stores'
import { capitalize } from '$lib/utils'
interface Props {
/** undefined until the instance's login options are known. */
hasThirdParty: boolean | undefined
}
let { hasThirdParty }: Props = $props()
let instanceName = $derived($whitelabelNameStore ? capitalize($whitelabelNameStore) : 'Windmill')
</script>
<!-- Held blank rather than defaulted while the options load: a third-party login also creates
the account, so guessing either way flashes copy that is wrong for half the instances. -->
<div class="min-h-14">
{#if hasThirdParty !== undefined}
<h2 class="text-center text-2xl font-semibold tracking-tight text-emphasis">
{hasThirdParty ? `Log in or sign up to ${instanceName}` : `Log in to ${instanceName}`}
</h2>
<p class="mt-2 text-center text-xs text-secondary">
{hasThirdParty
? 'Log in or sign up with any of the methods below'
: 'Log in with your email and password'}
</p>
{/if}
</div>
@@ -1,11 +1,34 @@
<script>
<script lang="ts">
import Uptodate from './Uptodate.svelte'
import Version from './Version.svelte'
import DarkModeToggle from './sidebar/DarkModeToggle.svelte'
import WindmillIcon from './icons/WindmillIcon.svelte'
import { whitelabelNameStore } from '$lib/stores'
import { capitalize } from '$lib/utils'
interface Props {
/** Off for the login page, which puts the mark and the instance name in the middle. */
showBrand?: boolean
}
let { showBrand = true }: Props = $props()
</script>
<div class="absolute top-0 right-0 text-2xs text-gray-800 italic px-3 py-1">
<div class="flex flex-row gap-2">
<div class="absolute top-0 inset-x-0 flex items-center justify-between gap-2 px-4 py-2">
<!-- The brand belongs to the instance, not to Windmill: a whitelabelled one keeps its own
name and drops the mark, the same trade the centered logo used to make. -->
<div class="flex items-center gap-2 text-base font-semibold text-emphasis">
{#if showBrand}
{#if $whitelabelNameStore}
{capitalize($whitelabelNameStore)}
{:else}
<WindmillIcon height="28px" width="28px" />
Windmill
{/if}
{/if}
</div>
<div class="flex flex-row gap-2 text-2xs text-gray-800 italic">
<DarkModeToggle forcedDarkMode={false} />
<div class="font-mono flex-col flex p-2 justify-center">
+13 -2
View File
@@ -18,6 +18,10 @@
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
}
@@ -32,11 +36,14 @@
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(
@@ -76,7 +83,7 @@
<TextInput
bind:this={textareaRef}
size="md"
error={red}
error={hasError}
bind:value={password}
underlyingInputEl="textarea"
inputProps={{
@@ -85,6 +92,8 @@
placeholder,
rows: minRows ?? 3,
autocomplete,
'aria-invalid': hasError ? 'true' : undefined,
'aria-describedby': describedBy,
onblur: (e) => onBlur?.(e),
onkeydown: (e) => {
onKeyDown?.(e)
@@ -99,13 +108,15 @@
<TextInput
bind:this={inputRef}
size="md"
error={red}
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') {
@@ -1,16 +1,30 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
interface Props {
height?: string
width?: string
size?: number
height?: number
width?: number
class?: string
}
let { height = '24px', width = '24px' }: Props = $props()
let {
size = undefined,
height: heightProp = 24,
width: widthProp = 24,
class: clazz = ''
}: Props = $props()
const { width, height } = $derived(
size ? { width: size, height: size } : { width: widthProp, height: heightProp }
)
</script>
<!-- #E24329, with #FC6D26 / #FCA326, per https://design.gitlab.com/brand-design/color (Orange 03p/02p/01p, "colors from our core logo"). -->
<svg
{width}
{height}
class={twMerge(clazz)}
viewBox="85.24 85.26 209.509 209.509"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
@@ -1,16 +1,30 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
interface Props {
height?: string
width?: string
size?: number
height?: number
width?: number
class?: string
}
let { height = '24px', width = '24px' }: Props = $props()
let {
size = undefined,
height: heightProp = 24,
width: widthProp = 24,
class: clazz = ''
}: Props = $props()
const { width, height } = $derived(
size ? { width: size, height: size } : { width: widthProp, height: heightProp }
)
</script>
<!-- #F25022 / #7FBA00 / #00A4EF / #FFB900 per the official logo asset linked from Microsoft's logo third-party usage guidance. Microsoft forbids recolouring the symbol, so it stays full-colour on both themes. -->
<svg
{width}
{height}
class={twMerge(clazz)}
viewBox="0.727 0.727 510.545 510.545"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
@@ -1,24 +0,0 @@
<script lang="ts">
interface Props {
size?: number
style?: string
class?: string
}
let { size = 16, style = 'fill: white;', class: clazz = '' }: Props = $props()
</script>
<svg
version="1.2"
xmlns="http://www.w3.org/2000/svg"
viewBox="-73.492 -24.813 905.625 905.625"
height={`${size}px`}
class={clazz}
{style}
>
<title>auth0-svg</title>
<path
fill="currentColor"
d="M160.183716,48.171844 C209.065292,32.885063 258.390625,21.436710 308.871613,15.252386 C317.292297,14.220791 325.753601,13.356311 334.222168,12.919684 C346.325989,12.295630 355.267456,21.589764 353.666168,34.529034 C349.868744,65.213501 348.537781,96.246513 341.092957,126.439857 C326.677368,184.904236 298.682648,235.976761 256.968506,279.307343 C213.326065,324.640991 160.987228,355.220551 99.454086,369.350647 C86.185066,372.397675 72.675255,374.468994 59.210712,376.568817 C48.252338,378.277832 37.322960,368.554504 37.025162,357.267883 C36.888950,352.105377 37.000465,346.936340 37.000454,341.770264 C37.000340,275.444489 37.000156,209.118698 37.000484,142.792908 C37.000629,113.734810 53.605553,87.790001 80.711502,77.096191 C106.802269,66.802895 133.414459,57.831257 160.183716,48.171844 z M473.292908,18.782619 C506.484009,23.757580 538.685669,31.232409 570.535767,40.198071 C606.031982,50.190132 640.835815,62.258595 674.995361,76.160843 C703.688293,87.838272 720.524536,113.183640 720.506226,144.306686 C720.465149,214.123505 720.485657,283.940338 720.495422,353.757172 C720.497620,369.678192 710.628052,378.153381 694.790466,376.099304 C645.966858,369.767212 600.276794,354.609619 558.938660,327.513123 C494.944519,285.566010 450.524231,228.198090 425.068176,156.124725 C416.448669,131.720474 411.208069,106.565437 408.906525,80.760696 C407.739166,67.672127 405.700104,54.662220 404.473694,41.577518 C403.999908,36.522667 403.805481,31.137602 405.026886,26.291929 C407.479736,16.560717 413.882874,12.037321 423.787903,12.980925 C440.178345,14.542362 456.506195,16.760437 473.292908,18.782619 z M578.363403,464.347748 C615.531311,446.366882 654.581116,435.908630 694.772705,429.284882 C697.708374,428.801056 700.752136,428.857452 703.745483,428.859894 C714.899902,428.868988 720.458801,434.582397 718.691895,445.492645 C716.009277,462.057404 713.484558,478.739502 709.260254,494.946198 C694.406738,551.932251 666.318115,602.423340 631.191589,649.183105 C575.914368,722.766968 508.061218,783.052307 432.927094,835.552307 C431.018921,836.885681 429.046265,838.127625 427.153870,839.482422 C422.472626,842.833557 417.533234,844.040344 412.248444,841.064758 C406.852631,838.026794 404.090637,833.218079 404.209808,827.096191 C404.527344,810.783264 404.724487,794.456909 405.573883,778.167480 C407.684601,737.688477 413.126068,697.680664 423.966522,658.536804 C436.856445,611.992310 456.846161,569.063904 488.758606,532.264648 C513.768555,503.424927 543.806946,481.217163 578.363403,464.347748 z M100.761627,612.221680 C75.728386,572.424377 56.110188,530.629822 45.663021,484.961426 C42.630875,471.706848 40.741016,458.167358 38.833408,444.686859 C37.390755,434.492065 42.754341,427.939545 52.986912,429.076324 C69.119164,430.868469 85.245743,433.507080 101.069740,437.122314 C140.869492,446.215149 178.476105,460.947479 212.772552,483.436096 C263.321442,516.581604 297.953369,562.429321 320.055481,618.236816 C334.840118,655.568054 343.272247,694.426331 348.081482,734.171936 C351.726166,764.293335 353.368927,794.520508 353.156128,824.854004 C353.142120,826.849976 353.278076,828.905640 352.863708,830.830078 C350.998230,839.494263 340.934357,847.105835 330.938843,839.874878 C313.414551,827.197754 295.720337,814.735718 278.557312,801.583313 C248.662613,778.674316 220.522430,753.696289 193.948929,727.012268 C159.090790,692.009094 127.388931,654.396790 100.761627,612.221680 z"
/>
</svg>
@@ -1,23 +0,0 @@
<script lang="ts">
interface Props {
size?: number
style?: string
class?: string
}
let { size = 16, style = 'fill: white;', class: clazz = '' }: Props = $props()
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
height={`${size}px`}
viewBox="-22.545 -20.545 541.091 541.091"
class={clazz}
{style}
>
<!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path
fill="currentColor"
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
/>
</svg>
@@ -1,23 +0,0 @@
<script lang="ts">
interface Props {
size?: number
style?: string
class?: string
}
let { size = 16, style = 'fill: white;', class: clazz = '' }: Props = $props()
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
height={`${size}px`}
viewBox="-23.273 -23.273 558.545 558.545"
class={clazz}
{style}
>
<!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path
fill="currentColor"
d="M503.5 204.6L502.8 202.8L433.1 21.02C431.7 17.45 429.2 14.43 425.9 12.38C423.5 10.83 420.8 9.865 417.9 9.57C415 9.275 412.2 9.653 409.5 10.68C406.8 11.7 404.4 13.34 402.4 15.46C400.5 17.58 399.1 20.13 398.3 22.9L351.3 166.9H160.8L113.7 22.9C112.9 20.13 111.5 17.59 109.6 15.47C107.6 13.35 105.2 11.72 102.5 10.7C99.86 9.675 96.98 9.295 94.12 9.587C91.26 9.878 88.51 10.83 86.08 12.38C82.84 14.43 80.33 17.45 78.92 21.02L9.267 202.8L8.543 204.6C-1.484 230.8-2.72 259.6 5.023 286.6C12.77 313.5 29.07 337.3 51.47 354.2L51.74 354.4L52.33 354.8L158.3 434.3L210.9 474L242.9 498.2C246.6 500.1 251.2 502.5 255.9 502.5C260.6 502.5 265.2 500.1 268.9 498.2L300.9 474L353.5 434.3L460.2 354.4L460.5 354.1C482.9 337.2 499.2 313.5 506.1 286.6C514.7 259.6 513.5 230.8 503.5 204.6z"
/>
</svg>
@@ -1,23 +0,0 @@
<script lang="ts">
interface Props {
size?: number
style?: string
class?: string
}
let { size = 16, style = 'fill: white;', class: clazz = '' }: Props = $props()
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
height={`${size}px`}
viewBox="-26.545 -14.545 541.091 541.091"
class={clazz}
{style}
>
<!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path
fill="currentColor"
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
/>
</svg>
@@ -1,23 +0,0 @@
<script lang="ts">
interface Props {
size?: number
style?: string
class?: string
}
let { size = 16, style = 'fill: white;', class: clazz = '' }: Props = $props()
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
height={`${size}px`}
viewBox="-20.364 11.636 488.727 488.727"
class={clazz}
{style}
>
><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path
fill="currentColor"
d="M0 32h214.6v214.6H0V32zm233.4 0H448v214.6H233.4V32zM0 265.4h214.6V480H0V265.4zm233.4 0H448V480H233.4V265.4z"
/>
</svg>
@@ -1,26 +0,0 @@
<script lang="ts">
interface Props {
size?: number
style?: string
class?: string
}
let { size = 16, style = 'fill: white;', class: clazz = '' }: Props = $props()
</script>
<svg
version="1.2"
xmlns="http://www.w3.org/2000/svg"
viewBox="-73.0 -72.5 1740.0 1740.0"
height={`${size}px`}
class={clazz}
{style}
>
<title>oktaddd-svg</title>
<path
fill="currentColor"
id="Layer"
fill-rule="evenodd"
d="m877 11.5l-32.8 403.8c-15.5-1.8-31-2.7-46.9-2.7-19.9 0-39.4 1.3-58.5 4.4l-18.6-195.6c-0.4-6.2 4.5-11.6 10.7-11.6h33.2l-16-197.8c-0.4-6.2 4.5-11.6 10.2-11.6h108.5c6.2 0 11.1 5.4 10.2 11.1zm-166.1 410.4c-35 8-68.2 20.8-98.8 37.6l-84.6-177.5c-2.6-5.3 0-11.9 5.8-14.2l31.4-11.5-82.8-180.6c-2.6-5.3 0-11.9 5.8-14.2l101.9-37.2c5.7-2.2 11.9 1.4 13.7 7.1 0.4 0 107.6 390.5 107.6 390.5zm-357.4-278l234.3 330.2c-29.7 19.5-56.7 42.5-79.7 69.1l-140.5-138.1c-4.4-4.4-3.9-11.5 0.5-15.5l25.7-21.3-139.6-141.2c-4.4-4.4-3.9-11.5 0.9-15.5l82.9-69.5c4.8-4 11.5-3.1 15 1.8zm136.9 421.4c-21.3 27.9-38.5 58.9-51.4 92.1l-178.9-81.9c-5.8-2.2-8-9.3-4.9-14.6l16.8-28.8-179.8-85c-5.3-2.6-7.5-9.3-4.4-14.6l54-93.8c3.1-5.3 10.2-7.1 15.1-3.6zm-466 24.8c0.9-6.2 7.1-9.7 12.8-8.4l392 102.3c-10.2 33.2-15.9 68.2-16.8 104.5l-196.2-16c-6.2-0.4-10.7-6.2-9.3-12.4l5.7-32.7-198-18.6c-6.2-0.5-10.1-6.2-9.3-12.4l18.6-106.7zm-15 264.7l403.5-37.2c1.8 35.9 8.9 70.9 19.9 103.6l-189.5 52.3c-5.8 1.3-12-2.2-12.9-8.4l-5.7-32.8-192.3 50c-5.7 1.4-11.9-2.2-12.8-8.4l-19.1-106.7c-0.9-6.2 3.1-11.9 9.3-12.4zm63.4 280.7c-3.1-5.3-0.9-11.9 4.4-14.6l365.9-173.5c13.7 32.7 32.3 63.3 54.4 90.7l-160.3 114.2c-4.9 3.6-12 2.2-15.1-3.1l-16.8-29.2-163.4 112.9c-4.9 3.5-12 1.8-15.1-3.5 0 0-54.5-93.9-54-93.9zm525.7-9.3l-111.6 162c-3.5 5.4-10.6 6.2-15.5 2.3l-25.7-21.7-115.1 162c-3.6 4.9-10.2 5.8-15.1 1.8l-83.3-69.5c-4.8-4-5.3-11.1-0.9-15.5l284.8-288.2c24.4 25.6 52.3 48.2 82.4 66.8zm-138.6 395.8c-5.8-2.2-8.4-8.9-5.8-14.2l168.8-368.3c31 15.9 64.7 27.9 99.7 34.5l-49.7 190.4c-1.3 5.7-7.9 9.3-13.7 7.1l-31.4-11.5-52.7 191.7c-1.8 5.7-8 9.3-13.8 7l-101.8-37.1zm337.5-340c19.9 0 39.4-1.4 58.4-4.5l18.6 195.7c0.5 6.2-4.4 11.5-10.6 11.5h-33.2l15.9 197.9c0.9 6.2-3.9 11.5-10.1 11.5h-108.6c-5.7 0-10.6-5.3-10.2-11.5l32.8-403.7c15.5 2.2 31 3.1 47 3.1zm174.5-728.3c-31-15.5-64.2-27.5-99.7-34.5l49.6-190.4c1.8-5.8 8-9.3 13.8-7.1l31.4 11.5 52.7-191.7c1.8-5.7 8-9.3 13.8-7.1l101.8 37.2c5.8 2.2 8.5 8.4 5.8 14.2zm391.6-207.2l-284.9 288.2c-23.9-25.7-51.3-48.2-81.9-66.8l111.6-162.1c3.6-4.8 10.7-6.2 15.5-2.2l25.7 21.7 115.2-162c3.5-4.9 10.6-5.8 15-1.8l83.3 69.5c4.9 4 4.9 11.1 0.5 15.5zm153.7 227.1l-365.9 173.6c-14.2-32.8-32.3-63.4-54.5-90.8l160.3-114.2c4.9-4 12-2.2 15.1 3.1l16.8 28.8 163.5-112.9c4.9-3.1 11.9-1.8 15 3.5l54.5 93.9c3.1 5.3 1.4 11.9-4.4 14.6zm58 146.5l18.6 106.7c0.9 6.2-3.1 11.5-9.3 12.4l-403.5 37.6c-1.8-36.3-8.9-70.8-19.9-103.6l189.6-52.2c5.7-1.8 11.9 2.2 12.8 8.4l5.8 32.8 192.2-50.1c5.8-1.3 12 2.3 12.8 8.5zm-18.6 391.3l-392-102.3c10.2-33.2 16-68.1 16.9-104.4l196.2 15.9c6.2 0.9 10.2 6.2 9.3 12.4l-5.8 32.8 198 18.6c6.2 0.8 10.2 6.1 9.3 12.3l-18.6 106.7c-0.9 6.2-7.1 9.8-12.8 8.5zm-104.1 243.9c-3.1 5.3-10.2 6.6-15.1 3.5l-333.5-230.2c21.3-27.9 38.5-58.9 51.4-92.1l178.9 81.9c5.8 2.7 8 9.3 4.9 14.6l-16.8 28.8 179.8 85c5.3 2.7 7.5 9.3 4.4 14.6zm-446.5-135.9c29.7-19 56.3-42.5 79.8-69l140.4 138.1c4.4 4.4 4.4 11.5-0.5 15.5l-25.7 21.2 139.6 141.3c4 4.4 4 11.5-0.9 15.4l-82.8 69.6c-4.5 3.9-11.6 3.1-15.1-1.8l-234.3-330.3zm-1.8 449.8c-5.7 2.2-11.9-1.3-13.7-7.1l-107.2-390.4c35-8 68.2-20.8 98.8-37.7l84.6 177.6c2.6 5.7 0 12.4-5.8 14.1l-31.4 11.5 82.8 180.7c2.6 5.7 0 11.9-5.8 14.1l-101.8 37.2z"
/>
</svg>
@@ -337,6 +337,12 @@ import type { Component } from 'svelte'
* Most variants are the pre-audit artwork painted with currentColor. GoogleCloudIcon is
* greyscale instead — four grey tones rather than one flat colour — because Google's
* cloud loses its internal shape when flattened. If the artwork changes, change both.
*
* Brand marks carry a viewBox that centres the artwork in a box 24/22 of its bounding
* size, so the mark occupies the same safe area a lucide glyph does on its 24 grid.
* Vendor SVGs come with whatever padding the vendor chose — none for Google, 10% for
* GitHub — so a raw viewBox makes them render at visibly different sizes from each other
* and from the lucide icons beside them. Re-derive the viewBox when replacing artwork.
*/
export const APP_TO_ICON_COMPONENT = {
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
clearPendingLoginMethod,
confirmPendingLoginMethod,
getLastLoginMethod,
markLoginMethodPending,
rememberLoginMethod
} from './lastLoginMethod'
describe('lastLoginMethod', () => {
beforeEach(() => localStorage.clear())
it('only promotes a pending method when told a session exists', () => {
markLoginMethodPending({ kind: 'oauth', provider: 'gitlab' })
expect(getLastLoginMethod()).toBeUndefined()
confirmPendingLoginMethod()
expect(getLastLoginMethod()).toEqual({ kind: 'oauth', provider: 'gitlab' })
// the pending slot is spent, so a later confirm cannot re-promote it
localStorage.removeItem('lastLoginMethod')
confirmPendingLoginMethod()
expect(getLastLoginMethod()).toBeUndefined()
})
it('forgets a pending method that never became a login', () => {
rememberLoginMethod({ kind: 'password' })
markLoginMethodPending({ kind: 'oauth', provider: 'github' })
clearPendingLoginMethod()
confirmPendingLoginMethod()
expect(getLastLoginMethod()).toEqual({ kind: 'password' })
})
it('ignores stored values it does not recognise', () => {
for (const stored of [
'not json',
'{}',
'"password"',
'{"kind":"oauth"}',
'{"kind":"carrier"}'
]) {
localStorage.setItem('lastLoginMethod', stored)
expect(getLastLoginMethod()).toBeUndefined()
}
})
})
+61
View File
@@ -0,0 +1,61 @@
// The login method that last worked on this browser, so the card can put it first and badge it.
// Purely a hint: it is never read for anything but ordering and a label.
export type LastLoginMethod =
| { kind: 'password' }
| { kind: 'oauth'; provider: string }
| { kind: 'saml' }
const CONFIRMED_KEY = 'lastLoginMethod'
// OAuth and SAML leave the page before the outcome is known, so the method is parked here and
// only promoted once a session exists — otherwise an abandoned provider would claim the badge.
const PENDING_KEY = 'lastLoginMethodPending'
function read(key: string): LastLoginMethod | undefined {
try {
const raw = localStorage.getItem(key)
if (!raw) return undefined
const parsed = JSON.parse(raw)
if (parsed?.kind === 'password' || parsed?.kind === 'saml') return parsed
if (parsed?.kind === 'oauth' && typeof parsed.provider === 'string') return parsed
return undefined
} catch {
return undefined
}
}
function write(key: string, method: LastLoginMethod | undefined) {
try {
if (method) localStorage.setItem(key, JSON.stringify(method))
else localStorage.removeItem(key)
} catch (e) {
console.error('Could not record the last login method', e)
}
}
export function getLastLoginMethod(): LastLoginMethod | undefined {
return read(CONFIRMED_KEY)
}
export function rememberLoginMethod(method: LastLoginMethod) {
write(CONFIRMED_KEY, method)
write(PENDING_KEY, undefined)
}
export function markLoginMethodPending(method: LastLoginMethod) {
write(PENDING_KEY, method)
}
export function clearPendingLoginMethod() {
write(PENDING_KEY, undefined)
}
/** Call only where a session is proven: whatever redirect was in flight is what worked. */
export function confirmPendingLoginMethod() {
const pending = read(PENDING_KEY)
if (pending) rememberLoginMethod(pending)
}
export function sameLoginMethod(a: LastLoginMethod | undefined, b: LastLoginMethod): boolean {
if (!a || a.kind !== b.kind) return false
return a.kind !== 'oauth' || a.provider === (b as { provider: string }).provider
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { loginErrorMessage } from './loginError'
describe('loginErrorMessage', () => {
it('maps the backend rejection to one message for a wrong email and a wrong password', () => {
expect(loginErrorMessage({ status: 400, body: 'Bad request: Invalid login' })).toBe(
'Invalid email or password.'
)
})
it('surfaces the messages the endpoint is known to produce', () => {
expect(
loginErrorMessage({
status: 400,
body: 'Bad request: Password login is disabled on this instance'
})
).toBe('Password login is disabled on this instance')
// The rate limiter's own wording is not echoed back: a 429 always gets this sentence.
expect(loginErrorMessage({ status: 429, body: 'Bad request: slow down' })).toBe(
'Too many login attempts. Please try again later.'
)
})
it('never surfaces server text it does not recognise, to an unauthenticated visitor', () => {
const sqlError = {
status: 400,
body: 'Bad request: SqlErr: error returned from database: relation "password" does not exist @backend/windmill-api-users/src/users.rs:123'
}
expect(loginErrorMessage(sqlError)).toBe('Could not sign you in. Please try again.')
expect(
loginErrorMessage({ status: 502, body: '<html><body>502 Bad Gateway</body></html>' })
).toBe('Could not sign you in. Please try again.')
expect(loginErrorMessage(new TypeError('Failed to fetch'))).toBe(
'Could not sign you in. Please try again.'
)
expect(loginErrorMessage({ status: 400, body: { error: { message: { nested: true } } } })).toBe(
'Could not sign you in. Please try again.'
)
})
})
+24
View File
@@ -0,0 +1,24 @@
// Only messages the login endpoint is known to produce are shown. Anything else — a SQL error
// (which the API also returns as a 400, with the query and a source location), a proxy's HTML
// error page — would otherwise be printed verbatim to an unauthenticated visitor.
const KNOWN_LOGIN_ERRORS = ['Password login is disabled on this instance']
const GENERIC_LOGIN_ERROR = 'Could not sign you in. Please try again.'
export function loginErrorMessage(err: any): string {
// The API returns errors as plain text, prefixed by their class (e.g. "Bad request: Invalid
// login"); ApiError.message is only the HTTP status text.
const raw = typeof err?.body === 'string' ? err.body : err?.body?.error?.message
const body = typeof raw === 'string' ? raw : ''
const detail = body.replace(/^(Bad request|Internal|Error): /, '').trim()
if (detail === 'Invalid login') {
return 'Invalid email or password.'
}
if (err?.status === 429) {
return 'Too many login attempts. Please try again later.'
}
if (KNOWN_LOGIN_ERRORS.includes(detail)) {
return detail
}
return GENERIC_LOGIN_ERROR
}
@@ -63,6 +63,7 @@
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { PanelLeftClose, PanelLeftOpen, Home, Play, Search, WandSparkles } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { confirmPendingLoginMethod } from '$lib/lastLoginMethod'
import { deepEqual } from 'fast-equals'
import { twMerge } from 'tailwind-merge'
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
@@ -315,6 +316,9 @@
}
}
const user = await getUserExt(workspace)
// getUserExt resolves to undefined on failure, so a user is the only proof of a
// session: without it a cancelled SSO round trip would claim the "Last used" badge.
if (user) confirmPendingLoginMethod()
// Every workspace change starts a fetch without cancelling the one before it,
// so a slow response can land after a faster one for the workspace the user
// has since moved to. The store must describe the active workspace: letting a
@@ -10,17 +10,18 @@
enterpriseLicense,
whitelabelNameStore
} from '$lib/stores'
import { classNames, emptyString, parseQueryParams } from '$lib/utils'
import { emptyString, parseQueryParams } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { WindmillIcon } from '$lib/components/icons'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import { WindmillIcon } from '$lib/components/icons'
import { clearStores } from '$lib/storeUtils'
import { setLicense } from '$lib/enterpriseUtils'
import Login from '$lib/components/Login.svelte'
import LoginHeading from '$lib/components/LoginHeading.svelte'
import { onMount } from 'svelte'
import { refreshSuperadmin } from '$lib/refreshUser'
import { isValidLogoutRedirect, toSameOriginRelativePath } from '$lib/logoutRedirect'
import { confirmPendingLoginMethod } from '$lib/lastLoginMethod'
const email = page.url.searchParams.get('email') ?? ''
const password = page.url.searchParams.get('password') ?? ''
@@ -37,8 +38,10 @@
const sameOriginRd = toSameOriginRelativePath(rawRd)
const rd = sameOriginRd ?? rawRd
let showPassword = false
let firstTime = $state(false)
// A third-party login creates the account on first use, so the page only offers sign-up
// once the instance has one configured. undefined until the card reports what it loaded.
let hasThirdParty = $state<boolean | undefined>(undefined)
function clearWindmillCloudCookies() {
const domain = window.location.hostname
@@ -118,6 +121,8 @@
async function redirectIfNecessary() {
await UserService.getCurrentEmail()
// Reached only with a session: an SSO round trip that landed back here worked.
confirmPendingLoginMethod()
redirectUser()
}
@@ -134,30 +139,32 @@
}
</script>
<div
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
>
<LoginPageHeader />
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<!-- Anchored to the top, not centered: the card grows when the password form opens or an
error appears, and centering would slide the mark and the fields under the pointer. -->
<div class="flex flex-col pt-24 pb-12 sm:px-6 lg:px-8 relative bg-surface-secondary min-h-screen">
<!-- The one page that keeps the mark in the middle: it names the instance you are logging
into, so the header's corner lockup would just say it twice. -->
<LoginPageHeader showBrand={false} />
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<div class="mx-auto flex justify-center">
{#if !$enterpriseLicense || !$whitelabelNameStore}
<WindmillIcon height="80px" width="80px" spin="slow" />
<WindmillIcon height="48px" width="48px" spin="slow" />
{/if}
</div>
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
Log in or sign up
</h2>
<p class="mt-2 text-center text-xs text-secondary">
Log in or sign up with any of the methods below
</p>
<div class="mt-6">
<LoginHeading {hasThirdParty} />
</div>
</div>
<div
class={classNames('mt-8 sm:mx-auto sm:w-full sm:max-w-xl', showPassword ? 'mb-16' : 'mb-48')}
>
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<Login {firstTime} {rd} {error} {password} {email} autoRedirect={false} />
<div class="mt-6 sm:mx-auto sm:w-full sm:max-w-sm">
<Login
{firstTime}
{rd}
{error}
{password}
{email}
autoRedirect={false}
onOptionsLoaded={(options) => (hasThirdParty = options.hasThirdParty)}
/>
</div>
</div>
@@ -212,7 +212,6 @@
<CenteredModal
title="Approval for resuming of {isWac ? 'workflow' : 'flow'}"
disableLogo
centerVertically={false}
>
{#if error}
@@ -173,7 +173,7 @@
<ScheduleEditor bind:this={scheduleEditor} />
<CenteredModal title="Approval for resuming of flow" disableLogo centerVertically={false}>
<CenteredModal title="Approval for resuming of flow" centerVertically={false}>
{#if error}
<div class="space-y-6">
{#if error.startsWith('Not authorized:')}
@@ -0,0 +1,210 @@
<script lang="ts">
// Every state the login page can be in, at /kitchen_sink/login.
//
// Each frame renders the real <Login> card inside the real page chrome, fed a fixed
// instance configuration through `preview` so nothing here talks to the API: signing in
// always fails with the credentials error, and the provider buttons don't navigate.
import Login, { type LoginPreview } from '$lib/components/Login.svelte'
import LoginHeading from '$lib/components/LoginHeading.svelte'
import { WindmillIcon } from '$lib/components/icons'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Select from '$lib/components/select/Select.svelte'
import { goto } from '$lib/navigation'
import { onMount } from 'svelte'
// A pixel-accurate login card that silently drops what is typed into it has no business on
// a deployed instance, where browsers would offer to autofill real credentials into it.
let enabled = $state(false)
onMount(() => {
enabled = import.meta.env.DEV
if (!enabled) {
goto('/')
}
})
// The width the login page ships with today is max-w-sm; the others are for comparison.
const widths = [
{ label: 'xs — 320px', value: 'sm:max-w-xs' },
{ label: 'sm — 384px (current)', value: 'sm:max-w-sm' },
{ label: 'md — 448px', value: 'sm:max-w-md' },
{ label: 'lg — 512px', value: 'sm:max-w-lg' }
]
let width = $state('sm:max-w-sm')
const google = { type: 'google', displayName: 'Google' }
const github = { type: 'github', displayName: 'GitHub' }
const microsoft = { type: 'microsoft', displayName: 'Microsoft' }
const okta = { type: 'okta', displayName: 'Okta' }
const auth0 = { type: 'auth0', displayName: 'Auth0' }
const gitlab = { type: 'gitlab', displayName: 'GitLab' }
const nextcloud = { type: 'nextcloud', displayName: 'Nextcloud' }
const pocketid = { type: 'pocketid', displayName: 'Pocket ID' }
const custom = { type: 'keycloak', displayName: 'Keycloak' }
type Variant = {
title: string
note: string
preview: LoginPreview
firstTime?: boolean
email?: string
}
const variants: Variant[] = [
{
title: 'Self-hosted, password only',
note: 'No OAuth provider and no SAML configured: the form is the whole page.',
preview: {}
},
{
title: 'Password only, SMTP configured',
note: 'Adds the "Forgot password?" link — it only shows when the instance can send email.',
preview: { smtpConfigured: true }
},
{
title: 'First-time setup',
note: 'Fresh instance: default credentials prefilled with the welcome line above them.',
preview: { smtpConfigured: false },
firstTime: true
},
{
title: 'One provider',
note: 'Password form is collapsed behind "Log in without third-party".',
preview: { logins: [google] }
},
{
title: 'Three providers',
note: 'One button per row, whatever the count.',
preview: { logins: [google, github, microsoft], smtpConfigured: true }
},
{
title: 'Four providers',
note: 'Four buttons, still stacked; the card grows by a row per provider.',
preview: { logins: [google, github, microsoft, okta], smtpConfigured: true }
},
{
title: 'Everything at once',
note: '8 known providers + a custom one + SAML: the tallest the card ever gets.',
preview: {
logins: [google, github, microsoft, okta, auth0, gitlab, nextcloud, pocketid, custom],
saml: true,
smtpConfigured: true
}
},
{
title: 'Deep link with the email prefilled',
note: '/user/login?email=… opens the form even though providers are configured.',
preview: { logins: [google, github], smtpConfigured: true },
email: 'someone@windmill.dev'
},
{
title: 'Last used: Google',
note: 'Badged and moved to the top of the list; the rest keep their order.',
preview: {
logins: [github, google, microsoft],
smtpConfigured: true,
lastUsed: { kind: 'oauth', provider: 'google' }
}
},
{
title: 'Last used: email and password',
note: 'The form leads the card, already open, with the providers under the divider.',
preview: {
logins: [github, google],
smtpConfigured: true,
lastUsed: { kind: 'password' }
}
},
{
title: 'Long provider name, last used',
note: 'A custom provider with a long display name, badged: the worst case for the label.',
preview: {
logins: [
{ type: 'keycloak', displayName: 'Acme Corporation Single Sign-On' },
github,
{ type: 'authentik', displayName: 'Authentik (staging)' }
],
smtpConfigured: true,
lastUsed: { kind: 'oauth', provider: 'keycloak' }
}
},
{
title: 'Last used: SSO',
note: 'SAML leads the list when it is what worked last, ahead of the OAuth buttons.',
preview: {
logins: [google, github],
saml: true,
smtpConfigured: true,
lastUsed: { kind: 'saml' }
}
},
{
title: 'SAML only',
note: 'A single SSO button, password login still reachable underneath.',
preview: { saml: true }
},
{
title: 'SSO only, password login disabled',
note: 'No password form and no "Log in without third-party" escape hatch.',
preview: { logins: [okta], saml: true, disablePasswordLogin: true }
},
{
title: 'Cloud',
note: 'isCloudHosted(): adds the contact line above the form and the terms/privacy footer.',
preview: { logins: [google, github, microsoft], cloud: true, smtpConfigured: true }
},
{
title: 'Auto-login redirecting',
note: 'auto_login is set: everything is hidden behind "Signing you in…" while the redirect happens.',
preview: { logins: [okta], autoRedirecting: true }
}
]
</script>
{#if enabled}
<div class="p-4 bg-surface-secondary min-h-screen">
<div class="flex flex-wrap items-end gap-4 mb-4">
<div>
<h1 class="text-lg font-semibold text-emphasis">Login page states</h1>
<p class="text-xs text-secondary">
Real cards, fixed instance config, no API calls. Sign in always fails so the error state
is one click away (click twice for the shake).
</p>
</div>
<div class="w-56">
<div class="text-xs font-semibold text-emphasis mb-1">Card width</div>
<Select items={widths} bind:value={width} />
</div>
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-4">
{#each variants as variant (variant.title)}
{@const hasThirdParty = (variant.preview.logins?.length ?? 0) > 0 || !!variant.preview.saml}
<div class="border rounded-lg overflow-hidden bg-surface-secondary">
<div class="px-4 py-2 border-b bg-surface">
<div class="text-sm font-semibold text-emphasis">{variant.title}</div>
<div class="text-xs text-secondary">{variant.note}</div>
</div>
<div class="py-10 px-4">
<div class="sm:mx-auto sm:w-full {width}">
<div class="mx-auto flex justify-center">
<WindmillIcon height="48px" width="48px" spin="slow" />
</div>
<div class="mt-6">
<LoginHeading {hasThirdParty} />
</div>
</div>
<div class="mt-6 sm:mx-auto sm:w-full {width}">
<Login
preview={variant.preview}
firstTime={variant.firstTime ?? false}
email={variant.email}
autoRedirect={false}
/>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
@@ -1,12 +1,9 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { WindmillIcon } from '$lib/components/icons'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { UserService } from '$lib/gen'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
let email = $state('')
let loading = $state(false)
@@ -42,16 +39,11 @@
}
</script>
<div
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
>
<!-- Anchored to the top, not centered: the card grows when the password form opens or an
error appears, and centering would slide the mark and the fields under the pointer. -->
<div class="flex flex-col pt-24 pb-12 sm:px-6 lg:px-8 relative bg-surface-secondary min-h-screen">
<LoginPageHeader />
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="mx-auto flex justify-center">
{#if !$enterpriseLicense || !$whitelabelNameStore}
<WindmillIcon height="80px" width="80px" spin="slow" />
{/if}
</div>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
Reset password
</h2>
@@ -60,10 +52,7 @@
</p>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="mt-6 sm:mx-auto sm:w-full sm:max-w-sm">
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if submitted}
<div class="text-center space-y-4">
@@ -2,14 +2,11 @@
import { goto } from '$lib/navigation'
import { tick } from 'svelte'
import { page } from '$app/state'
import { WindmillIcon } from '$lib/components/icons'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { UserService } from '$lib/gen'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import Password from '$lib/components/Password.svelte'
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
const token = page.url.searchParams.get('token') ?? ''
@@ -70,16 +67,11 @@
}
</script>
<div
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
>
<!-- Anchored to the top, not centered: the card grows when the password form opens or an
error appears, and centering would slide the mark and the fields under the pointer. -->
<div class="flex flex-col pt-24 pb-12 sm:px-6 lg:px-8 relative bg-surface-secondary min-h-screen">
<LoginPageHeader />
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="mx-auto flex justify-center">
{#if !$enterpriseLicense || !$whitelabelNameStore}
<WindmillIcon height="80px" width="80px" spin="slow" />
{/if}
</div>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
{success ? 'Password Reset' : 'Set New Password'}
</h2>
@@ -88,10 +80,7 @@
{/if}
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="mt-6 sm:mx-auto sm:w-full sm:max-w-sm">
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if !token}
<div class="text-center space-y-4">
+6 -1
View File
@@ -551,12 +551,17 @@ const config = {
animation: {
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite',
'zoom-in': 'zoom-in 0.25s ease-in-out',
'fade-out': 'fade-out 1s ease-in-out'
'fade-out': 'fade-out 1s ease-in-out',
shake: 'shake 0.2s linear both'
},
keyframes: {
'spin-counter-clockwise': {
to: { transform: 'rotate(-360deg)' }
},
shake: {
'25%, 75%': { transform: 'translateX(-2px)' },
'50%': { transform: 'translateX(2px)' }
},
'zoom-in': {
'0%': { transform: 'scale(0.95)' },
'100%': { transform: 'scale(1)' }