feat(team): team-name rule and name-taken handling to match auth (orca-cloud#697)

This commit is contained in:
Neil
2026-11-09 08:15:01 -08:00
parent 6bd93fc20b
commit cbae761f08
8 changed files with 54 additions and 11 deletions
@@ -1,4 +1,4 @@
import { ORCA_ORG_NAME_MAX_LENGTH } from '../../../shared/orca-org-management'
import { isValidOrgName, ORCA_ORG_NAME_MAX_LENGTH } from '../../../shared/orca-org-management'
import type { OrcaProfileOrgCreateResult } from '../../../shared/orca-org-management'
import { ipcMain } from 'electron'
import type {
@@ -85,7 +85,7 @@ function orgMemberRemoveArgsFromUnknown(args: unknown): OrcaProfileOrgMemberRemo
function orgCreateNameFromUnknown(args: unknown): string {
const record = args && typeof args === 'object' ? (args as Record<string, unknown>) : {}
const name = typeof record.name === 'string' ? record.name.trim() : ''
if (!name || name.length > ORCA_ORG_NAME_MAX_LENGTH) {
if (!name || name.length > ORCA_ORG_NAME_MAX_LENGTH || !isValidOrgName(name)) {
throw new Error('invalid_orca_org_name')
}
return name
@@ -1,3 +1,4 @@
import { isValidOrgName } from '../../shared/orca-org-management'
import type { OrcaProfileOrgCreateResult } from '../../shared/orca-org-management'
import { randomUUID } from 'node:crypto'
import { addDevOrganization, removeDevOrganization } from './profile-cloud-dev-auth'
@@ -160,7 +161,7 @@ export function removeDevOrcaCloudOrgMember(
export function createDevOrcaCloudOrg(name: string): OrcaProfileOrgCreateResult {
const trimmed = name.trim()
if (!trimmed) {
if (!isValidOrgName(trimmed)) {
return { status: 'invalid' }
}
const orgId = `dev-team-${randomUUID().slice(0, 8)}`
@@ -285,6 +285,9 @@ export async function createOrcaProfileOrg(
if (result.error.statusCode === 429) {
return { status: 'rate-limited' }
}
if (result.error.statusCode === 409) {
return { status: 'name-taken' }
}
return result.error.statusCode === 400
? { status: 'invalid' }
: { status: 'failed', error: result.error.message }
@@ -1,4 +1,4 @@
import { ORCA_ORG_NAME_MAX_LENGTH } from '../../../../../shared/orca-org-management'
import { isValidOrgName, ORCA_ORG_NAME_MAX_LENGTH } from '../../../../../shared/orca-org-management'
import { useId, useState } from 'react'
import { ArrowRight, Users } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -27,6 +27,8 @@ export function TeamCreateView(props: TeamCreateViewProps): React.JSX.Element {
const id = useId()
const [name, setName] = useState('')
const trimmed = name.trim()
const nameValid = isValidOrgName(trimmed)
const showRule = trimmed.length > 0 && !nameValid
return (
<div className="space-y-6">
<section
@@ -53,7 +55,7 @@ export function TeamCreateView(props: TeamCreateViewProps): React.JSX.Element {
className="mt-4 space-y-2"
onSubmit={(event) => {
event.preventDefault()
if (trimmed && !props.creating && !props.unavailable) {
if (nameValid && !props.creating && !props.unavailable) {
props.onCreate(trimmed)
}
}}
@@ -70,8 +72,8 @@ export function TeamCreateView(props: TeamCreateViewProps): React.JSX.Element {
'auto.components.billing.team.teamNamePlaceholder',
'Acme Engineering'
)}
aria-invalid={props.error ? true : undefined}
aria-describedby={props.error ? `${id}-error` : undefined}
aria-invalid={props.error || showRule ? true : undefined}
aria-describedby={props.error ? `${id}-error` : showRule ? `${id}-rule` : undefined}
onChange={(event) => setName(event.target.value)}
readOnly={props.creating}
aria-disabled={props.creating || undefined}
@@ -79,7 +81,7 @@ export function TeamCreateView(props: TeamCreateViewProps): React.JSX.Element {
/>
<Button
type="submit"
disabled={!props.creating && (!trimmed || props.unavailable)}
disabled={!props.creating && (!nameValid || props.unavailable)}
aria-disabled={props.creating || undefined}
>
{props.creating ? <LoadingSpinner className="size-3.5" /> : null}
@@ -97,6 +99,14 @@ export function TeamCreateView(props: TeamCreateViewProps): React.JSX.Element {
)}
</p>
) : null}
{showRule && !props.error ? (
<p id={`${id}-rule`} className="text-xs text-muted-foreground">
{t(
'auto.components.billing.team.createInvalid2',
'Use letters, numbers, spaces or underscores (up to 64).'
)}
</p>
) : null}
{props.error ? (
<p id={`${id}-error`} role="alert" className="text-xs text-destructive">
{props.error}
@@ -151,7 +151,12 @@ export function createTeamFailureCopy(
case 'created':
return null
case 'invalid':
return t('auto.components.billing.team.createInvalid', 'Choose a different team name.')
return t(
'auto.components.billing.team.createInvalid2',
'Use letters, numbers, spaces or underscores (up to 64).'
)
case 'name-taken':
return t('auto.components.billing.team.nameTaken', 'That team name is taken. Try another.')
case 'unsupported':
return t(
'auto.components.billing.team.createUnavailable',
+3 -2
View File
@@ -20313,7 +20313,6 @@
"createdToast": "{{value0}} created",
"createFailed": "Could not create the team. Try again.",
"createHint": "Share one Orca Cloud plan with your teammates.",
"createInvalid": "Choose a different team name.",
"createTitle": "Create a team",
"createUnavailable": "Team creation isn’t available yet. Ask a teammate to invite you.",
"createWorkspace": "Create a cloud workspace",
@@ -20413,7 +20412,9 @@
"nowAdmin": "{{value0}} is now an admin",
"nowMember": "{{value0}} is now a member",
"nowOwner": "{{value0}} is now an owner",
"perSeatPerMonth": "{{value0}} per seat per month"
"perSeatPerMonth": "{{value0}} per seat per month",
"createInvalid2": "Use letters, numbers, spaces or underscores (up to 64).",
"nameTaken": "That team name is taken. Try another."
},
"manageBilling": "Manage billing",
"comparePlans": "Compare plans",
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { isValidOrgName } from './orca-org-management'
describe('isValidOrgName', () => {
it('accepts the sign-in provider character set up to 64 characters', () => {
expect(isValidOrgName('Acme Engineering')).toBe(true)
expect(isValidOrgName('team_42')).toBe(true)
expect(isValidOrgName('a'.repeat(64))).toBe(true)
})
it('rejects punctuation, empty names and names over 64 characters', () => {
expect(isValidOrgName('Acme, Inc.')).toBe(false)
expect(isValidOrgName(' ')).toBe(false)
expect(isValidOrgName('a'.repeat(65))).toBe(false)
})
})
+8
View File
@@ -9,12 +9,20 @@ export type OrcaProfileOrgCreateArgs = {
}
export const ORCA_ORG_NAME_MAX_LENGTH = 64
/** The sign-in provider's rule for team names: letters, digits, spaces and underscores. */
export const ORCA_ORG_NAME_PATTERN = /^[A-Za-z0-9_ ]{1,64}$/
export function isValidOrgName(name: string): boolean {
return ORCA_ORG_NAME_PATTERN.test(name.trim())
}
/** 'unsupported': this Orca Cloud auth deployment has no route for the action yet. */
export type OrcaProfileOrgCreateResult =
| { status: 'created'; orgId: string; name: string }
| { status: 'unconfigured' | 'reconnect-required' | 'unsupported' | 'rate-limited' }
| { status: 'invalid' }
/** Another team already uses this name. */
| { status: 'name-taken' }
| { status: 'failed'; error: string }
export type OrcaOrgInviteConflictReason = 'already_member' | 'already_invited'