fix: build the Google sign-in button from the design system

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtYCxXEn2WujwVvh5aZRCa
This commit is contained in:
Guilhem Lemouel
2026-08-21 10:17:32 +02:00
co-authored by Claude Opus 5
parent 5beb15baa4
commit 74137ca12a
5 changed files with 245 additions and 104 deletions
@@ -6,7 +6,7 @@
import DrawerContent from './common/drawer/DrawerContent.svelte'
import AppConnectInner from './AppConnectInner.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import GoogleSigninButton from './GoogleSigninButton.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import { addResourceTitle } from './resourceTypeDisplay'
@@ -22,6 +22,10 @@
disableChatOffset = false
}: Props = $props()
/** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a
* property of the click, and a prop would go stale between two different rows. */
let fillPath: string | undefined = $state(undefined)
let drawer: Drawer | undefined = $state()
let resourceType = $state('')
let step = $state(1)
@@ -32,29 +36,39 @@
let appConnectInner: AppConnectInner | undefined = $state(undefined)
let rtToLoad: string | undefined = $state('')
export async function open(rt?: string) {
/** `fill` connects into a resource that already exists, instead of creating one. */
export async function open(rt?: string, fill?: string) {
fillPath = fill
rtToLoad = rt
drawer?.openDrawer?.()
}
/**
* Once per opening. The reactive statement below re-runs both when `rtToLoad` changes and
* when `appConnectInner` binds, and a second `open()` runs `next()` a second time — which
* walks a resource type opened with one straight past the Connect button and into
* `window.open`. A popup opened from a reactive effect rather than the click is blocked,
* so the drawer then sits on "Finish connection in popup window" with no popup.
*/
let openedFor: string | undefined = undefined
function onRtToLoadChange(rtToLoad: string | undefined) {
if (openedFor === rtToLoad) return
openedFor = rtToLoad
appConnectInner?.open(rtToLoad)
}
const dispatch = createEventDispatcher()
let darkMode: boolean = $state(false)
run(() => {
appConnectInner && onRtToLoadChange(rtToLoad)
})
</script>
<DarkModeObserver bind:darkMode />
<Drawer
bind:this={drawer}
on:close={() => {
step = 1
openedFor = undefined
dispatch('close')
}}
size="700px"
@@ -83,22 +97,26 @@
on:refresh
express={expressOAuthSetup}
{workspace}
{fillPath}
/>
{#snippet actions()}
<div class="flex gap-1">
{#if step > 1}
<Button variant="default" on:click={appConnectInner?.back ?? (() => {})}>Back</Button>
<!-- Only when the user came through the type picker: opening the drawer for one
resource type skips step 1, so Back would land on a list they never chose from. -->
{#if step > 1 && !rtToLoad}
<Button variant="default" unifiedSize="md" onClick={() => appConnectInner?.back()}>
Back
</Button>
{/if}
{#if isGoogleSignin}
<button {disabled} onclick={appConnectInner?.next}>
<img
class="h-10 w-auto object-contain"
src={darkMode ? '/google_signin_dark.png' : '/google_signin_light.png'}
alt="Google sign-in"
/>
</button>
<GoogleSigninButton {disabled} onClick={() => appConnectInner?.next()} />
{:else}
<Button variant="accent" {disabled} on:click={appConnectInner?.next ?? (() => {})}>
<Button
variant="accent"
unifiedSize="md"
{disabled}
onClick={() => appConnectInner?.next()}
>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
@@ -2,6 +2,7 @@
import AppConnectInner from '$lib/components/AppConnectInner.svelte'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Button } from '$lib/components/common'
import GoogleSigninButton from '$lib/components/GoogleSigninButton.svelte'
import { workspaceStore } from '$lib/stores'
import { onMount, untrack } from 'svelte'
@@ -16,6 +17,7 @@
let step = $state(1)
let disabled = $state(false)
let manual = $state(true)
let isGoogleSignin = $state(false)
let appConnect: AppConnectInner | undefined = $state(undefined)
@@ -41,18 +43,24 @@
<div class="flex flex-row-reverse w-full pb-2 shrink-0">
<div class="flex gap-2">
{#if step > 2}
<Button variant="default" on:click={appConnect?.back ?? (() => {})}>Back</Button>
<Button variant="default" unifiedSize="md" onClick={() => appConnect?.back()}>
Back
</Button>
{/if}
<Button variant="accent" {disabled} on:click={appConnect?.next ?? (() => {})}>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
Next
{:else}
Save
{/if}
</Button>
{#if isGoogleSignin}
<GoogleSigninButton {disabled} onClick={() => appConnect?.next()} />
{:else}
<Button variant="accent" unifiedSize="md" {disabled} onClick={() => appConnect?.next()}>
{#if step == 2 && !manual}
Connect
{:else if step == 1}
Next
{:else}
Save
{/if}
</Button>
{/if}
</div>
</div>
{/if}
@@ -64,6 +72,7 @@
bind:resourceType
bind:disabled
bind:manual
bind:isGoogleSignin
on:error
on:refresh
/>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Button, type ButtonType } from '$lib/components/common'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
interface Props {
disabled?: boolean
unifiedSize?: ButtonType.UnifiedSize
onClick?: ((e?: Event) => void) | undefined
}
let { disabled = false, unifiedSize = 'md', onClick = undefined }: Props = $props()
</script>
<!-- developers.google.com/identity/branding-guidelines requires Google's own branding on the
control that starts a Google sign-in, and allows a custom button for it as long as the G
mark is unaltered and the label is one of theirs. So this is a design-system Button rather
than the PNG Google ships, which carried no hover, focus or disabled state and stood at a
different height than the buttons beside it. -->
<Button variant="default" {unifiedSize} {disabled} startIcon={{ icon: GoogleIcon }} {onClick}>
Sign in with Google
</Button>
@@ -0,0 +1,43 @@
<script lang="ts">
import { fly } from 'svelte/transition'
import { CheckCircle2 } from 'lucide-svelte'
interface Props {
/** Sized by the row, not the caller: 20px is what an integration logo needs to stay legible. */
icon: import('svelte').Snippet
title: import('svelte').Snippet
detail?: import('svelte').Snippet
action: import('svelte').Snippet
/** Plays the confirmation flash over the action once. */
flash?: boolean
}
let { icon, title, detail, action, flash = false }: Props = $props()
</script>
<!-- One row for both lists on the setup step. Data tables and credentials are the same
thing to the reader — something the import could not configure, with an action that
configures it — so they get the same icon size, spacing and text block. -->
<li class="flex items-center gap-3 rounded-md border border-border-light px-3 py-2 text-xs">
<div class="shrink-0">{@render icon()}</div>
<!-- No gap and no leading override: `text-xs` already carries `leading-4`, and the two
lines are one block of text, not two stacked items. -->
<div class="flex min-w-0 flex-1 flex-col">
{@render title()}
{@render detail?.()}
</div>
<!-- The confirmation flash is the one from SaveButton: the work itself happens
elsewhere — a drawer, a wizard — so only the overlay is reused here. The button
stays live underneath either way; being configured is a state, not a dead end. -->
<div class="relative shrink-0 overflow-hidden rounded-md">
{@render action()}
{#if flash}
<div
class="absolute inset-0 flex items-center justify-center rounded-md bg-green-200 dark:bg-green-800"
transition:fly={{ y: -10, duration: 300 }}
>
<CheckCircle2 class="h-5 w-5 text-green-700 dark:text-green-300" />
</div>
{/if}
</div>
</li>
@@ -1,7 +1,6 @@
<script lang="ts">
import { ResourceService, WorkspaceService } from '$lib/gen'
import { ArrowLeft, Check, CheckCircle2, Database, Loader2, X } from 'lucide-svelte'
import { fly } from 'svelte/transition'
import { ArrowLeft, Check, Database, Loader2, X } from 'lucide-svelte'
import { tick } from 'svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { Button } from '$lib/components/common'
@@ -12,6 +11,10 @@
import { resource } from 'runed'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
import ImportSetupRow from '$lib/components/ImportSetupRow.svelte'
import AppConnectDrawer from '$lib/components/AppConnectDrawer.svelte'
import { OauthService } from '$lib/gen'
import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay'
import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall'
import type { ProjectMigration } from '$lib/components/workspaceSettings/projectBundle'
import { sendUserToast } from '$lib/toast'
@@ -40,6 +43,8 @@
migrations: ProjectMigration[]
status: 'unconfigured' | 'running' | 'done' | 'failed'
error?: string
/** Plays the confirmation flash once, right after the run that configured it. */
justSaved: boolean
}
/** A resource the project shipped that needed filling in. */
@@ -69,6 +74,24 @@
// The wizard needs the instance-database pool and a confirmation host; the settings
// page owns them there, so this step owns them here.
// Which resource types this instance has an OAuth client for. A resource whose type is
// in here can be connected instead of hand-filled, which for an OAuth type is the
// difference between clicking Connect and pasting a token that expires in an hour.
// Empty when no superadmin has configured any client — then every row falls back to
// the editor, which is the only thing that would work anyway.
const oauthConnects = resource(
() => workspace,
async () => {
try {
return (await OauthService.listOauthConnects()).map((c) => c.name)
} catch {
return []
}
}
)
const connectable = $derived(new Set(oauthConnects.current ?? []))
let appConnect: AppConnectDrawer | undefined = $state(undefined)
const customInstanceDbs = resource([() => workspace], SettingService.listCustomInstanceDbs)
const confirmationModal = createAsyncConfirmationModal()
let wizardOpen = $state(false)
@@ -122,7 +145,8 @@
return {
name,
migrations: enabled.filter((m) => m.datatable_name === name),
status: (missing.includes(name) ? 'unconfigured' : 'done') as Row['status']
status: (missing.includes(name) ? 'unconfigured' : 'done') as Row['status'],
justSaved: false
}
})
projectResources = exportData.resources ?? []
@@ -232,6 +256,13 @@
for (const m of row.migrations) await applyOneMigration(workspace, slug, m)
row.status = 'done'
row.error = undefined
// One-shot, cleared by name rather than by reference: `load()` rebuilds the row
// objects, so the one holding the flag when it fires may not be this one.
row.justSaved = true
setTimeout(() => {
const current = rows.find((r) => r.name === name)
if (current) current.justSaved = false
}, 1500)
} catch (e: any) {
row.status = 'failed'
row.error = e?.body ?? e?.message ?? String(e)
@@ -267,9 +298,11 @@
<div class="flex flex-col gap-4">
<div>
<h2 class="text-sm font-semibold text-emphasis">Finish setting up</h2>
<!-- Reads as what the user gets out of it, not as what the import failed to do:
the step is skippable, so it has to say why finishing is worth their time. -->
<p class="mt-0.5 text-xs text-secondary">
The project is imported. What is left is the part it could not bring with it — connections and
credentials this workspace has to supply.
Your project is imported. For its apps and flows to actually run, they need a place to store
data and credentials for the services they use — the import can't supply those for you.
</p>
</div>
@@ -286,31 +319,33 @@
<!-- Named and explained: the row underneath is a table called `main`, which
says nothing to someone meeting the concept for the first time. -->
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">
<span class="text-xs font-semibold text-emphasis">
Data table{rows.length === 1 ? '' : 's'} to set up ({rows.length})
</span>
<p class="text-xs text-tertiary">
Where apps and flows keep the data they read and write. Point {rows.length === 1
? 'it'
: 'them'} at a database and the tables get created for you.
<p class="text-xs font-normal text-secondary">
Where apps and flows keep the data they read and write.
</p>
</div>
{/if}
<ul class="flex flex-col gap-3">
<ul class="flex flex-col gap-1.5">
{#each rows as row (row.name)}
<li class="flex items-center gap-2 rounded-md border border-border-light px-3 py-2 text-xs">
{#if row.status === 'done'}
<Check size={14} class="shrink-0 text-emerald-600" />
{:else if row.status === 'running'}
<Loader2 size={14} class="shrink-0 animate-spin text-blue-500" />
{:else if row.status === 'failed'}
<X size={14} class="shrink-0 text-red-500" />
{:else}
<Database size={14} class="shrink-0 text-secondary" />
{/if}
<span class="min-w-0 flex-1">
<span class="block truncate font-mono text-emphasis">{row.name}</span>
<span class="block truncate text-tertiary">
<ImportSetupRow flash={row.justSaved}>
{#snippet icon()}
{#if row.status === 'done'}
<Check size={20} class="text-emerald-600" />
{:else if row.status === 'running'}
<Loader2 size={20} class="animate-spin text-blue-500" />
{:else if row.status === 'failed'}
<X size={20} class="text-red-500" />
{:else}
<Database size={20} class="text-secondary" />
{/if}
{/snippet}
{#snippet title()}
<span class="min-w-0 truncate font-mono text-emphasis">{row.name}</span>
{/snippet}
{#snippet detail()}
<span class="truncate text-secondary">
{#if row.status === 'done'}
{row.migrations.length} migration{row.migrations.length === 1 ? '' : 's'} run
{:else if row.status === 'running'}
@@ -321,72 +356,78 @@
not configured yet
{/if}
</span>
</span>
<!-- The wizard owns creating a data table: picking or provisioning the
database, writing the config, and reporting the connection. This step
only says which name it needs and runs the migrations afterwards. -->
<Button
variant={row.status === 'done' ? 'subtle' : 'accent'}
unifiedSize="sm"
disabled={working}
onClick={() => openWizard(row.name)}
>
{row.status === 'done' ? 'Configured' : 'Set up'}
</Button>
</li>
{/snippet}
{#snippet action()}
<!-- The wizard owns creating a data table: picking or provisioning the
database, writing the config, and reporting the connection. This step
only says which name it needs and runs the migrations afterwards. -->
<Button
variant={row.status === 'done' ? 'subtle' : 'accent'}
unifiedSize="sm"
disabled={working}
onClick={() => openWizard(row.name)}
>
{row.status === 'done' ? 'Configured' : 'Set up'}
</Button>
{/snippet}
</ImportSetupRow>
{/each}
</ul>
{#if blanks.length > 0}
<div class="flex flex-col gap-2">
<span class="text-xs font-normal text-secondary">Credentials to fill ({blanks.length})</span
<span class="text-xs font-semibold text-emphasis"
>Credentials to fill ({blanks.length})</span
>
<ul class="flex flex-col gap-1.5">
{#each blanks as b (b.path)}
<li
class="flex items-center gap-2 rounded-md border border-border-light px-3 py-2 text-xs"
>
{#if b.done}
<Check size={14} class="shrink-0 text-emerald-600" />
{:else}
<!-- The integration's own icon, not a generic key: the row is identified by
which provider it is for. Falls back to a generic box when unknown. -->
<span class="shrink-0">
<IconedResourceType name={b.resourceType} silent width="14px" height="14px" />
</span>
{/if}
<span class="min-w-0 flex-1">
<span class="block truncate font-mono text-emphasis">{b.path}</span>
<span class="block truncate text-tertiary">
{b.done
? b.resourceType
: `${b.resourceType}${
b.missing.length > 0 ? ` · missing ${b.missing.join(', ')}` : ''
}`}
</span>
</span>
<!-- The confirmation flash is the one from SaveButton: the save itself
happens in the resource drawer, so only the overlay is reused here.
The button stays live either way — "Saved" is a state, not a dead end. -->
<div class="relative overflow-hidden rounded-md">
{@const canConnect = !b.done && connectable.has(b.resourceType)}
<!-- Laid out like the resource type rows in the Add-a-resource drawer: the
integration's own icon, its product name, and the raw identifier demoted
beside it. The path only matters when two resources share a type, so it
stops being the thing the eye lands on. -->
<ImportSetupRow flash={b.justSaved}>
{#snippet icon()}
{#if b.done}
<Check size={20} class="text-emerald-600" />
{:else}
<IconedResourceType name={b.resourceType} silent width="20px" height="20px" />
{/if}
{/snippet}
{#snippet title()}
<div class="flex min-w-0 flex-row items-baseline gap-2">
<span class="min-w-0 truncate text-emphasis">
{resourceTypeDisplayName(b.resourceType)}
</span>
<span class="min-w-0 truncate font-mono text-2xs font-normal text-hint">
{b.path}
</span>
</div>
{/snippet}
{#snippet detail()}
{#if !b.done && b.missing.length > 0}
<span class="truncate text-secondary">
Missing {b.missing.join(', ')}
</span>
{/if}
{/snippet}
{#snippet action()}
<!-- Connect where the instance has a client for this type: asking for an
OAuth resource by hand means pasting an access token that dies within
the hour, since only a token Windmill obtained itself gets refreshed. -->
<Button
variant={b.done ? 'subtle' : 'accent'}
unifiedSize="sm"
disabled={working}
onClick={() => resourceEditor?.initEdit(b.path)}
onClick={() =>
canConnect
? appConnect?.open(b.resourceType, b.path)
: resourceEditor?.initEdit(b.path)}
>
{b.done ? 'Saved' : 'Fill in'}
{b.done ? 'Saved' : canConnect ? 'Connect' : 'Fill in'}
</Button>
{#if b.justSaved}
<div
class="absolute inset-0 flex items-center justify-center rounded-md bg-green-200 dark:bg-green-800"
transition:fly={{ y: -10, duration: 300 }}
>
<CheckCircle2 class="h-5 w-5 text-green-700 dark:text-green-300" />
</div>
{/if}
</div>
</li>
{/snippet}
</ImportSetupRow>
{/each}
</ul>
</div>
@@ -465,10 +506,19 @@
<ConfirmationModal {...confirmationModal.props} />
<!-- The destination is not the workspace the app is in until the run switches to it,
so the editor is told which one explicitly. -->
so the editor is told which one explicitly.
Saving re-reads only the resources, never `load()`: a credential cannot change which
data tables the project ships or which ones the workspace has, and `load()` raises
`loading`, which replaces both lists with the spinner — so every save looked like the
whole step had reloaded. -->
<ResourceEditorDrawer
bind:this={resourceEditor}
{workspace}
onSaved={() => void load()}
onRestored={() => void load()}
onSaved={() => void refreshBlanks()}
onRestored={() => void refreshBlanks()}
/>
<!-- `on:refresh` fires once the connection has been written into the stub — the same moment
a save is — so the rows settle the same way either route was taken. -->
<AppConnectDrawer bind:this={appConnect} {workspace} on:refresh={() => void refreshBlanks()} />