fix: sanitize the inferred username, and stop counting an unread setup step as clean

`loadUsernamePolicy` derived a username by stripping dots, so `O'Connor`
and `alice+demo` both produced values the `proper_username` constraint
refuses — posted invisibly by the simple form, which then failed with
nothing on screen explaining why. `usernameFromName` keeps only `[\w-]`
and answers undefined when nothing usable is left, which is already the
form's cue to open the full one.

The credentials step offers Finish when the export could not be read,
since it cannot tell what is outstanding — and that landed in
`template_setup` as `filled`, the bucket meaning the step came out
clean. It reports whether it checked anything, and an unread step counts
as `unchecked`.

Also drops an orphaned `.sqlx` entry left by the create-at-signup query
this branch abandoned, and rewrites the stepper's first-frame comment,
which argued from a meaning of `resourceCount` that main has narrowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRjnaHLwjpHN84gNNxah9
This commit is contained in:
Guilhem Lemouel
2026-09-04 15:18:56 +02:00
co-authored by Claude Opus 5
parent 4558b766eb
commit 7d0794adfa
5 changed files with 56 additions and 37 deletions
@@ -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"
}
@@ -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 →
</Button>
@@ -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}
/>
</div>
+18 -1
View File
@@ -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()
})
})
+12 -1
View File
@@ -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<UsernamePolicy> {
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 }
}