diff --git a/backend/.sqlx/query-9d5fb7a829a1631328cf9a34be41895619c0c69848fa8ea60f46e5edb676fec1.json b/backend/.sqlx/query-9d5fb7a829a1631328cf9a34be41895619c0c69848fa8ea60f46e5edb676fec1.json deleted file mode 100644 index 4174dabe94..0000000000 --- a/backend/.sqlx/query-9d5fb7a829a1631328cf9a34be41895619c0c69848fa8ea60f46e5edb676fec1.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(\n SELECT 1 FROM workspace_invite WHERE email = $1\n UNION ALL\n SELECT 1 FROM usr WHERE email = $1\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "9d5fb7a829a1631328cf9a34be41895619c0c69848fa8ea60f46e5edb676fec1" -} diff --git a/frontend/src/lib/components/ImportSetupStep.svelte b/frontend/src/lib/components/ImportSetupStep.svelte index e052ecf9bc..51eaf725f8 100644 --- a/frontend/src/lib/components/ImportSetupStep.svelte +++ b/frontend/src/lib/components/ImportSetupStep.svelte @@ -54,7 +54,12 @@ showHeading?: boolean /** Fill the height given, actions pinned to the bottom. See ImportProjectStep. */ fillHeight?: boolean - onFinish: () => void + /** + * Finished. `checked` is false where the export could not be read: the step then has no + * idea what is outstanding, so it offers Finish rather than blocking — and a caller + * counting outcomes must not read that as a step that came out clean. + */ + onFinish: (checked: boolean) => void onBack?: () => void } @@ -1051,7 +1056,7 @@ variant="accent" unifiedSize="sm" disabled={working || loading || (outstanding > 0 && !loadError)} - onClick={onFinish} + onClick={() => onFinish(!loadError)} > Finish setup → diff --git a/frontend/src/lib/components/home/ImportProjectModal.svelte b/frontend/src/lib/components/home/ImportProjectModal.svelte index 445383f70d..404a5005c6 100644 --- a/frontend/src/lib/components/home/ImportProjectModal.svelte +++ b/frontend/src/lib/components/home/ImportProjectModal.svelte @@ -149,8 +149,14 @@ // The counters' key vocabularies, enumerated here so the whole set is reviewable at once. type AbandonStage = 'running' | 'setup' | 'done' | 'idle' - type SetupOutcome = 'filled' | 'skipped' | 'none' - type SetupBucket = 'filled' | 'none' | 'skipped_1' | 'skipped_2_5' | 'skipped_6plus' + type SetupOutcome = 'filled' | 'skipped' | 'none' | 'unchecked' + type SetupBucket = + | 'filled' + | 'none' + | 'unchecked' + | 'skipped_1' + | 'skipped_2_5' + | 'skipped_6plus' // Set for the closing that Finish itself asks for, since that closing reaches `dismiss()` // by the same falling edge as the X. @@ -159,9 +165,10 @@ /** * How the credentials step ended, counted alongside the import itself: `filled` only when * nothing was left outstanding — the step disables Finish until then — `none` where the - * project asked for nothing, and a `skipped_*` bucket carrying roughly how many rows were - * walked away from, since skipping with one credential left and skipping with eight are - * different problems. + * project asked for nothing, `unchecked` where the step could not read the export and so + * offered Finish over lists it never filled, and a `skipped_*` bucket carrying roughly how + * many rows were walked away from, since skipping with one credential left and skipping + * with eight are different problems. * * A bucket rather than `value`: `value` is an increment, so counting rows there would make * `skipped` a sum of rows while its siblings count imports — two units in one counter, and @@ -188,11 +195,12 @@ const IMPORT_PAGE = 'Import the project' const SETUP_PAGE = 'Fill credentials' let currentPage = $derived(onSetupStep ? SETUP_PAGE : IMPORT_PAGE) - // Whether this import ends on the credentials step, known before it runs: every resource - // the project ships arrives as an empty stub, so a project with any is one to fill in. - // `setup.needed` is the real answer and only lands with the export, which also knows about - // data tables — this is what lets the stepper name both steps from the first frame rather - // than growing one mid-flow. + // Whether this import ends on the credentials step, predicted before it runs so the stepper + // can name both steps from the first frame rather than growing one mid-flow. The hub's + // count is every resource the project ships, while the step only asks about the ones + // something in it points at, so this errs towards naming a step the dialog then skips — + // `setup.needed` is the real answer and lands with the export. Forward navigation is + // blocked on that one, so an over-named step is a label, never a page with nothing on it. let setupExists = $derived((project?.counts?.resources ?? 0) > 0 || setup.needed || onSetupStep) // The box height, decided when the dialog opens and left alone. It cannot follow @@ -251,7 +259,7 @@ {folder} showHeading={false} onSkip={(outstanding) => finish('skipped', outstanding)} - onFinish={() => finish('filled')} + onFinish={(checked) => finish(checked ? 'filled' : 'unchecked')} onBack={execution ? () => (onSetupStep = false) : undefined} /> diff --git a/frontend/src/lib/workspaceCreation.test.ts b/frontend/src/lib/workspaceCreation.test.ts index 2eede78871..55230538e7 100644 --- a/frontend/src/lib/workspaceCreation.test.ts +++ b/frontend/src/lib/workspaceCreation.test.ts @@ -7,7 +7,7 @@ vi.mock('./stores', () => ({ usersWorkspaceStore: { set: () => {} } })) vi.mock('./storeUtils', () => ({ switchWorkspace: () => {} })) vi.mock('./cloud', () => ({ isCloudHosted: () => false })) -import { defaultWorkspaceName } from './workspaceCreation' +import { defaultWorkspaceName, usernameFromName } from './workspaceCreation' describe('defaultWorkspaceName', () => { it('names the workspace after the person, not the address', () => { @@ -36,3 +36,20 @@ describe('defaultWorkspaceName', () => { expect(defaultWorkspaceName(undefined, '@example.com')).toBe('My workspace') }) }) + +describe('usernameFromName', () => { + // The `proper_username` constraint is `^[\w-]+$`, so a suggestion outside it is posted and + // then refused by the database, with the form showing nothing that explains why. + it('keeps only what the username constraint accepts', () => { + expect(usernameFromName("O'Connor")).toBe('oconnor') + expect(usernameFromName('alice+demo')).toBe('alicedemo') + expect(usernameFromName('Jean-Luc')).toBe('jean-luc') + expect(usernameFromName('ada.lovelace')).toBe('adalovelace') + }) + + it('answers undefined when nothing usable is left', () => { + // The caller opens the full form instead of prefilling something unusable. + expect(usernameFromName('++')).toBeUndefined() + expect(usernameFromName('')).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/workspaceCreation.ts b/frontend/src/lib/workspaceCreation.ts index 70863de7fb..3a39613870 100644 --- a/frontend/src/lib/workspaceCreation.ts +++ b/frontend/src/lib/workspaceCreation.ts @@ -45,6 +45,17 @@ export interface UsernamePolicy { suggested?: string } +/** + * A username the `proper_username` constraint accepts: `^[\w-]+$`, so word characters and + * hyphens and nothing else. Everything else is dropped rather than substituted — `O'Connor` + * is `oconnor`, not `o-connor` — and a name with nothing left of it answers undefined, which + * is the caller's cue to ask for one instead of posting a value the database refuses. + */ +export function usernameFromName(name: string): string | undefined { + const cleaned = name.toLowerCase().replace(/[^\w-]/g, '') + return cleaned === '' ? undefined : cleaned +} + /** * `createWorkspace` rejects a username when the instance automates them and * requires one when it does not, so the field only exists in the second case. @@ -58,7 +69,7 @@ export async function loadUsernamePolicy(): Promise { try { const me = await UserService.globalWhoami() const from = me.name ? me.name.split(' ')[0] : me.email.split('@')[0] - return { automate: false, suggested: from.replace(/\./g, '').toLowerCase() } + return { automate: false, suggested: usernameFromName(from) } } catch { return { automate: false } }