Merge pull request #538 from nwparker/create-speed/team-delete

feat(team): owners can delete a team from Settings › Team (TEAM-DELETE desktop)
This commit is contained in:
Neil
2026-11-12 07:31:40 -08:00
committed by GitHub
22 changed files with 1273 additions and 73 deletions
@@ -22,6 +22,15 @@ import {
removeOrcaProfileOrgMember,
revokeOrcaProfileOrgInvite
} from '../../orca-profiles/cloud-org/profile-cloud-org-members-service'
import {
checkOrcaProfileOrgDelete,
deleteOrcaProfileOrg
} from '../../orca-profiles/cloud-org/profile-cloud-org-delete-service'
import {
ORCA_ORG_DELETE_CONFIRM_MAX_LENGTH,
type OrcaProfileOrgDeleteCheckResult,
type OrcaProfileOrgDeleteResult
} from '../../../shared/orca-org-delete'
function orgMembersScopedArgs(args: unknown): { orgId: string; record: Record<string, unknown> } {
if (!args || typeof args !== 'object') {
@@ -91,7 +100,32 @@ function orgCreateNameFromUnknown(args: unknown): string {
return name
}
/** The name exactly as typed (auth compares it case-sensitively), so it is never trimmed here. */
function orgDeleteConfirmNameFromUnknown(value: unknown): string {
if (typeof value !== 'string' || !value || value.length > ORCA_ORG_DELETE_CONFIRM_MAX_LENGTH) {
throw new Error('invalid_orca_org_delete_confirm_name')
}
return value
}
export function registerOrcaProfileOrgMemberHandlers(): void {
ipcMain.handle(
'orcaProfiles:orgDeleteCheck',
async (_event, rawArgs: unknown): Promise<OrcaProfileOrgDeleteCheckResult> =>
checkOrcaProfileOrgDelete(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId)
)
ipcMain.handle(
'orcaProfiles:orgDelete',
async (_event, rawArgs: unknown): Promise<OrcaProfileOrgDeleteResult> => {
const { orgId, record } = orgMembersScopedArgs(rawArgs)
return deleteOrcaProfileOrg(getProfileUserDataPath(), {
orgId,
confirmName: orgDeleteConfirmNameFromUnknown(record.confirmName)
})
}
)
ipcMain.handle(
'orcaProfiles:orgInviteResend',
async (_event, rawArgs: unknown): Promise<OrcaProfileOrgMemberMutationResult> =>
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from '../cloud-auth/profile-cloud-auth-config'
import type { OrcaCloudSession } from '../cloud-session/profile-cloud-session-store'
import { OrcaCloudRequestError } from '../cloud-auth/profile-cloud-client'
import {
checkOrcaCloudOrgDelete,
deleteOrcaCloudOrg,
parseOrgDeleteCheck
} from './profile-cloud-org-delete-client'
import { mapOrgDeleteRequestError } from './profile-cloud-org-delete-service'
const base = 'https://auth.test'
const config: OrcaCloudAuthConfig = {
apiBaseUrl: base,
authorizeEndpoint: `${base}/a`,
sessionEndpoint: `${base}/s`,
refreshEndpoint: `${base}/r`,
capabilitiesEndpoint: `${base}/c`,
profileEndpoint: `${base}/p`,
orgEndpoint: `${base}/o`,
logoutEndpoint: `${base}/l`,
relayTokenEndpoint: `${base}/rt`,
connectorTokenEndpoint: `${base}/ct`,
relayDirectorUrl: base,
clientId: 'orca-desktop',
scope: 'openid'
}
const session: OrcaCloudSession = {
accessToken: 'token',
refreshToken: 'refresh',
expiresAt: 4_102_444_800_000,
capabilities: { flags: {}, refreshedAt: 0 }
}
function respond(status: number, body: unknown): void {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(body), { status }))
)
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('team delete client (TEAM-DELETE)', () => {
it('reads the pre-check, keeping known blockers only', async () => {
respond(200, {
deletable: false,
blockers: [
{ code: 'subscription_active', status: 'trialing' },
{ code: 'workspaces_exist', count: 3 },
{ code: 'something_new' }
]
})
await expect(checkOrcaCloudOrgDelete(config, session, 'org_1')).resolves.toEqual({
deletable: false,
blockers: [
{ code: 'subscription_active', status: 'trialing' },
{ code: 'workspaces_exist', count: 3 }
]
})
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe(`${base}/v1/desktop/orgs/org_1/delete-check`)
})
it('never calls a team deletable while a known blocker is listed', () => {
expect(
parseOrgDeleteCheck({ deletable: true, blockers: [{ code: 'workspaces_exist', count: 1 }] })
.deletable
).toBe(false)
})
it('sends the typed name as is and reads 409 not_deletable as blocked', async () => {
respond(409, { error: 'not_deletable', blockers: [{ code: 'workspaces_exist', count: 1 }] })
await expect(
deleteOrcaCloudOrg(config, session, { orgId: 'org_1', confirmName: ' Acme ' })
).resolves.toEqual({ kind: 'blocked', blockers: [{ code: 'workspaces_exist', count: 1 }] })
const init = vi.mocked(fetch).mock.calls[0]?.[1]
expect(init?.body).toBe(JSON.stringify({ confirmName: ' Acme ' }))
expect(init?.redirect).toBe('error')
})
it('throws other failures with their code, and maps them', async () => {
respond(400, { error: 'confirm_mismatch' })
const error = await deleteOrcaCloudOrg(config, session, {
orgId: 'org_1',
confirmName: 'x'
}).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(OrcaCloudRequestError)
expect(mapOrgDeleteRequestError(new OrcaCloudRequestError(503, 'billing_unavailable'))).toEqual(
{ status: 'unavailable' }
)
expect(mapOrgDeleteRequestError(new OrcaCloudRequestError(429, 'rate_limited'))).toEqual({
status: 'rate-limited'
})
expect(mapOrgDeleteRequestError(new OrcaCloudRequestError(403, 'not_owner'))).toEqual({
status: 'forbidden'
})
// No JSON code on a 404: this auth predates team delete.
expect(mapOrgDeleteRequestError(new OrcaCloudRequestError(404))).toEqual({
status: 'unsupported'
})
})
})
@@ -0,0 +1,69 @@
// Auth's team-delete routes (TEAM-DELETE). Same Bearer token, timeout and no-redirect rule as the
// other /v1/desktop/orgs routes.
import {
isPlainRecord,
normalizeOrgDeleteBlockers,
type OrcaOrgDeleteBlocker
} from '../../../shared/orca-org-delete'
import type { OrcaCloudAuthConfig } from '../cloud-auth/profile-cloud-auth-config'
import type { OrcaCloudSession } from '../cloud-session/profile-cloud-session-store'
import { OrcaCloudRequestError } from '../cloud-auth/profile-cloud-client'
import { extractErrorCode, orgMembersUrl, requestInit } from './profile-cloud-org-members-client'
export type OrgDeleteCheck = { deletable: boolean; blockers: OrcaOrgDeleteBlocker[] }
export type OrgDeleteOutcome =
| { kind: 'deleted' }
| { kind: 'blocked'; blockers: OrcaOrgDeleteBlocker[] }
async function readJson(response: Response): Promise<Record<string, unknown>> {
try {
const body: unknown = await response.json()
return isPlainRecord(body) ? body : {}
} catch {
return {}
}
}
export function parseOrgDeleteCheck(body: Record<string, unknown>): OrgDeleteCheck {
const blockers = normalizeOrgDeleteBlockers(body.blockers)
// Never call a team deletable while auth lists a blocker this desktop understands.
return { deletable: body.deletable === true && blockers.length === 0, blockers }
}
export async function checkOrcaCloudOrgDelete(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
orgId: string
): Promise<OrgDeleteCheck> {
const response = await fetch(
orgMembersUrl(config, orgId, '/delete-check'),
requestInit('GET', session.accessToken)
)
if (!response.ok) {
throw new OrcaCloudRequestError(response.status, await extractErrorCode(response))
}
return parseOrgDeleteCheck(await readJson(response))
}
/** 409 not_deletable comes back as `blocked` with auth's blockers; other failures throw. */
export async function deleteOrcaCloudOrg(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; confirmName: string }
): Promise<OrgDeleteOutcome> {
const response = await fetch(
orgMembersUrl(config, args.orgId, '/delete'),
requestInit('POST', session.accessToken, { confirmName: args.confirmName })
)
if (response.status === 409) {
const body = await readJson(response)
if (body.error === 'not_deletable') {
return { kind: 'blocked', blockers: normalizeOrgDeleteBlockers(body.blockers) }
}
throw new OrcaCloudRequestError(409, typeof body.error === 'string' ? body.error : undefined)
}
if (!response.ok) {
throw new OrcaCloudRequestError(response.status, await extractErrorCode(response))
}
return { kind: 'deleted' }
}
@@ -0,0 +1,96 @@
// Team delete for the active profile (TEAM-DELETE): the pre-check and the delete itself. Business
// refusals come back as values; only a 401 refreshes the session.
import type {
OrcaProfileOrgDeleteArgs,
OrcaProfileOrgDeleteCheckResult,
OrcaProfileOrgDeleteResult
} from '../../../shared/orca-org-delete'
import { ensureActiveOrcaProfile } from '../profile-index-store'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from '../cloud-auth/profile-cloud-auth-config'
import type { OrcaCloudRequestError } from '../cloud-auth/profile-cloud-client'
import { removeDevOrganization } from '../cloud-auth/profile-cloud-dev-auth'
import { runOrgMemberCall } from './profile-cloud-org-members-service'
import { checkOrcaCloudOrgDelete, deleteOrcaCloudOrg } from './profile-cloud-org-delete-client'
type CommonFailure = Exclude<
OrcaProfileOrgDeleteCheckResult,
{ status: 'ok' } | { status: 'unconfigured' } | { status: 'reconnect-required' }
>
export function mapOrgDeleteRequestError(error: OrcaCloudRequestError): CommonFailure {
switch (error.statusCode) {
case 403:
return { status: 'forbidden' }
case 404:
case 405:
// An unknown route (no JSON error code) means this auth deployment predates team delete.
return error.errorCode
? { status: 'failed', error: error.message }
: { status: 'unsupported' }
case 429:
return { status: 'rate-limited' }
case 503:
return { status: 'unavailable' }
default:
return { status: 'failed', error: error.message }
}
}
export async function checkOrcaProfileOrgDelete(
userDataPath: string,
orgId: string
): Promise<OrcaProfileOrgDeleteCheckResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return { status: 'ok', deletable: true, blockers: [] }
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
const result = await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
checkOrcaCloudOrgDelete(configState.config, session, orgId)
)
switch (result.status) {
case 'ok':
return { status: 'ok', ...result.value }
case 'reconnect-required':
return { status: 'reconnect-required' }
case 'request-error':
return mapOrgDeleteRequestError(result.error)
case 'failed':
return { status: 'failed', error: result.error }
}
}
export async function deleteOrcaProfileOrg(
userDataPath: string,
args: OrcaProfileOrgDeleteArgs
): Promise<OrcaProfileOrgDeleteResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
removeDevOrganization(args.orgId)
return { status: 'deleted' }
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
const result = await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
deleteOrcaCloudOrg(configState.config, session, args)
)
switch (result.status) {
case 'ok':
return result.value.kind === 'deleted'
? { status: 'deleted' }
: { status: 'blocked', blockers: result.value.blockers }
case 'reconnect-required':
return { status: 'reconnect-required' }
case 'request-error':
return result.error.statusCode === 400 && result.error.errorCode === 'confirm_mismatch'
? { status: 'confirm-mismatch' }
: mapOrgDeleteRequestError(result.error)
case 'failed':
return { status: 'failed', error: result.error }
}
}
@@ -114,11 +114,11 @@ export function normalizeRoster(value: unknown): OrcaOrgMembersRoster {
}
}
function orgMembersUrl(config: OrcaCloudAuthConfig, orgId: string, path: string): string {
export function orgMembersUrl(config: OrcaCloudAuthConfig, orgId: string, path: string): string {
return `${config.apiBaseUrl}/v1/desktop/orgs/${encodeURIComponent(orgId)}${path}`
}
async function extractErrorCode(response: Response): Promise<string | undefined> {
export async function extractErrorCode(response: Response): Promise<string | undefined> {
try {
const body = (await response.json()) as unknown
if (
@@ -135,7 +135,11 @@ async function extractErrorCode(response: Response): Promise<string | undefined>
}
// Reject redirects to avoid leaking access tokens; timeouts bound renderer IPC waits.
function requestInit(method: 'GET' | 'POST', accessToken: string, body?: unknown): RequestInit {
export function requestInit(
method: 'GET' | 'POST',
accessToken: string,
body?: unknown
): RequestInit {
return {
method,
headers: {
@@ -40,14 +40,14 @@ import {
revokeDevOrcaCloudOrgInvite
} from './profile-cloud-dev-org-members'
type OrgCallResult<T> =
export type OrgCallResult<T> =
| { status: 'ok'; value: T }
| { status: 'reconnect-required' }
| { status: 'request-error'; error: OrcaCloudRequestError }
| { status: 'failed'; error: string }
// Only 401 should trigger session refresh; return business/permission failures as values.
async function runOrgMemberCall<T>(
export async function runOrgMemberCall<T>(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
+10
View File
@@ -4,6 +4,12 @@ import type {
CloudSetupStartArgs,
CloudSetupStartResult
} from '../../shared/orca-cloud-setup'
import type {
OrcaProfileOrgDeleteArgs,
OrcaProfileOrgDeleteCheckArgs,
OrcaProfileOrgDeleteCheckResult,
OrcaProfileOrgDeleteResult
} from '../../shared/orca-org-delete'
import type {
OrcaProfileOrgCreateArgs,
OrcaProfileOrgCreateResult,
@@ -98,6 +104,10 @@ export type OrcaProfileApi = {
orgLeave: (args: OrcaProfileOrgLeaveArgs) => Promise<OrcaProfileOrgMemberMutationResult>
/** Creates a team owned by the signed-in user. */
orgCreate: (args: OrcaProfileOrgCreateArgs) => Promise<OrcaProfileOrgCreateResult>
/** Whether an owner can delete the team now, and what blocks it (TEAM-DELETE). */
orgDeleteCheck: (args: OrcaProfileOrgDeleteCheckArgs) => Promise<OrcaProfileOrgDeleteCheckResult>
/** Deletes the team for everyone; `confirmName` is the name exactly as typed. */
orgDelete: (args: OrcaProfileOrgDeleteArgs) => Promise<OrcaProfileOrgDeleteResult>
/** Fires when the browser comes back from Stripe Checkout or the Portal (orca:// link). */
onBillingReturn: (callback: (event: OrcaBillingReturn) => void) => () => void
/** Main asks for a billing re-read (cloud workspace created/woken, or the usage poll). */
+2
View File
@@ -81,6 +81,8 @@ export const orcaProfilesApi = {
orgInviteResend: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteResend', args),
orgLeave: (args) => ipcRenderer.invoke('orcaProfiles:orgLeave', args),
orgCreate: (args) => ipcRenderer.invoke('orcaProfiles:orgCreate', args),
orgDeleteCheck: (args) => ipcRenderer.invoke('orcaProfiles:orgDeleteCheck', args),
orgDelete: (args) => ipcRenderer.invoke('orcaProfiles:orgDelete', args),
onBillingReturn: (callback: (event: OrcaBillingReturn) => void): (() => void) => {
const listener = (_event: unknown, payload: OrcaBillingReturn): void => callback(payload)
ipcRenderer.on(ORCA_BILLING_RETURN_CHANNEL, listener)
@@ -0,0 +1,78 @@
import { Dialog } from '@/components/ui/dialog'
import { defineFixture } from '../../../component-fixtures/component-fixture'
import { TeamDeleteDialogView, type TeamDeleteDialogViewProps } from './TeamDeleteDialog'
const noop = (): void => {}
const base: TeamDeleteDialogViewProps = {
teamName: 'Acme Engineering',
typed: '',
submitting: false,
error: null,
waiting: false,
onTypedChange: noop,
onCancel: noop,
onSubmit: noop,
onBlockerAction: noop
}
function View(props: Partial<TeamDeleteDialogViewProps>): React.JSX.Element {
return (
<Dialog open>
<TeamDeleteDialogView {...base} {...props} />
</Dialog>
)
}
export const empty = defineFixture({ width: 720, render: () => <View /> })
export const typed = defineFixture({ width: 720, render: () => <View typed="Acme Engineering" /> })
export const mismatch = defineFixture({
width: 720,
render: () => (
<View
typed="acme engineering"
error={{ kind: 'mismatch', text: 'The team name doesn’t match.' }}
/>
)
})
export const unavailable = defineFixture({
width: 720,
render: () => (
<View
typed="Acme Engineering"
error={{
kind: 'message',
text: 'Couldn’t check the team right now, so nothing was deleted. Try again.'
}}
/>
)
})
export const rateLimited = defineFixture({
width: 720,
render: () => (
<View
typed="Acme Engineering"
waiting
error={{ kind: 'message', text: 'Too many attempts. Wait a minute, then try again.' }}
/>
)
})
export const blockedAtSubmit = defineFixture({
width: 720,
render: () => (
<View
typed="Acme Engineering"
error={{
kind: 'blocked',
blockers: [
{ code: 'subscription_active', status: 'active' },
{ code: 'workspaces_exist', count: 2 }
]
}}
/>
)
})
@@ -0,0 +1,123 @@
import { useId } from 'react'
import { LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useTranslate } from '@/i18n/use-translate'
import type { OrcaOrgDeleteBlocker } from '../../../../../shared/orca-org-delete'
import { TeamDeleteBlockers } from './TeamDeleteSection'
import type { TeamDeleteDialogError } from './team-delete-copy'
export type TeamDeleteDialogViewProps = {
teamName: string
typed: string
submitting: boolean
error: TeamDeleteDialogError | null
/** 429: [Delete team] stays disabled until the wait is over. */
waiting: boolean
onTypedChange: (value: string) => void
onCancel: () => void
onSubmit: () => void
onBlockerAction: (code: OrcaOrgDeleteBlocker['code']) => void
}
/** "Delete <team>?": type the exact name (case-sensitive, paste allowed) to enable the delete. */
export function TeamDeleteDialogView(props: TeamDeleteDialogViewProps): React.JSX.Element {
const t = useTranslate()
const fieldId = useId()
const mismatchId = useId()
const matches = props.typed === props.teamName
const canSubmit = matches && !props.submitting && !props.waiting
const mismatch = props.error?.kind === 'mismatch' ? props.error.text : null
return (
<DialogContent
showCloseButton={false}
className="sm:max-w-md"
onInteractOutside={(event) => {
if (props.submitting) {
event.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>
{t('auto.components.billing.team.delete.confirmTitle', 'Delete {{value0}}?', {
value0: props.teamName
})}
</DialogTitle>
<DialogDescription>
{t(
'auto.components.billing.team.delete.confirmBody',
'Everyone loses access to the team. Pending invites are cancelled. Its settings and team cloud secrets are deleted. Billing history is kept. This can’t be undone.'
)}
</DialogDescription>
</DialogHeader>
<form
className="space-y-2"
onSubmit={(event) => {
event.preventDefault()
if (canSubmit) {
props.onSubmit()
}
}}
>
<Label htmlFor={fieldId}>
{t('auto.components.billing.team.delete.typeToConfirm', 'Type {{value0}} to confirm', {
value0: props.teamName
})}
</Label>
<Input
id={fieldId}
value={props.typed}
autoComplete="off"
spellCheck={false}
autoFocus
aria-invalid={mismatch ? true : undefined}
aria-describedby={mismatch ? mismatchId : undefined}
onChange={(event) => props.onTypedChange(event.target.value)}
/>
{mismatch ? (
<p id={mismatchId} className="text-xs text-destructive-text">
{mismatch}
</p>
) : null}
</form>
{props.error?.kind === 'blocked' ? (
<div role="alert">
<TeamDeleteBlockers blockers={props.error.blockers} onAction={props.onBlockerAction} />
</div>
) : props.error?.kind === 'message' ? (
<p role="alert" className="text-xs text-destructive-text text-pretty">
{props.error.text}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={props.submitting}
onClick={props.onCancel}
>
{t('auto.components.confirmation.dialog.56f5c60e0c', 'Cancel')}
</Button>
<Button
type="button"
variant="destructive"
disabled={!canSubmit}
aria-busy={props.submitting || undefined}
onClick={props.onSubmit}
>
{props.submitting ? <LoaderCircle className="motion-safe:animate-spin" /> : null}
{t('auto.components.billing.team.delete.cta', 'Delete team')}
</Button>
</DialogFooter>
</DialogContent>
)
}
@@ -0,0 +1,85 @@
import { useId } from 'react'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTranslate } from '@/i18n/use-translate'
import type { OrcaOrgDeleteBlocker } from '../../../../../shared/orca-org-delete'
import { teamDeleteBlockerLines } from './team-delete-copy'
/** The pre-check: `checking` until auth answers; a failed check shows no blockers (auth re-checks). */
export type TeamDeleteCheck =
| { state: 'checking' }
| { state: 'ready'; blockers: OrcaOrgDeleteBlocker[] }
/** Each blocker's way out, shown as a link after its line. */
export function TeamDeleteBlockers({
blockers,
onAction
}: {
blockers: readonly OrcaOrgDeleteBlocker[]
onAction: (code: OrcaOrgDeleteBlocker['code']) => void
}): React.JSX.Element {
const t = useTranslate()
return (
<ul className="space-y-1">
{teamDeleteBlockerLines(blockers, t).map((line) => (
<li key={line.code} className="text-xs text-muted-foreground text-pretty">
{line.text}{' '}
<button
type="button"
className="rounded-sm whitespace-nowrap text-foreground underline underline-offset-2 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onAction(line.code)}
>
{line.actionLabel}
</button>
</li>
))}
</ul>
)
}
/** Settings › Team danger zone (owners only): "Delete <team>", styled like "Leave <team>". */
export function TeamDeleteSection({
teamName,
check,
onDelete,
onBlockerAction
}: {
teamName: string
check: TeamDeleteCheck
onDelete: () => void
onBlockerAction: (code: OrcaOrgDeleteBlocker['code']) => void
}): React.JSX.Element {
const t = useTranslate()
const titleId = useId()
const blockers = check.state === 'ready' ? check.blockers : []
const blocked = blockers.length > 0
return (
<section aria-labelledby={titleId} className="flex flex-wrap items-center gap-3 p-4">
<div className="min-w-[14rem] flex-1 space-y-1">
<h3 id={titleId} className="text-sm font-medium">
{t('auto.components.billing.team.delete.title', 'Delete {{value0}}', {
value0: teamName
})}
</h3>
<p className="text-xs text-muted-foreground text-pretty">
{t(
'auto.components.billing.team.delete.cardBody',
'Removes the team for everyone. This can’t be undone.'
)}
</p>
{blocked ? <TeamDeleteBlockers blockers={blockers} onAction={onBlockerAction} /> : null}
</div>
<Button
type="button"
variant="destructive-chip"
size="sm"
disabled={blocked}
aria-busy={check.state === 'checking' || undefined}
onClick={check.state === 'checking' ? undefined : onDelete}
>
<Trash2 />
{t('auto.components.billing.team.delete.cta', 'Delete team')}
</Button>
</section>
)
}
@@ -14,6 +14,8 @@ import { createTeamFailureCopy, mutationFailureCopy } from './team-copy'
import { clearTeamPageArrival, closeTeamPage, useTeamPageState } from './team-page-store'
import { useOrgRoster } from './use-org-roster'
import { useTeamActions } from './use-team-actions'
import { useTeamDelete } from './use-team-delete'
import { TeamDeleteSection } from './TeamDeleteSection'
import { openTeammateProfile } from '../../teammates/teammate-profile-store'
import { ORCA_CLOUD_RECIPE_ID } from '../../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
@@ -162,70 +164,97 @@ export function TeamPageBody({
const org =
orgId && arrival?.kind !== 'create' ? { orgId, name: cloud?.activeOrgName ?? orgId } : null
// Settings › Team only, owners only (TEAM-DELETE).
const teamDelete = useTeamDelete({
orgId: org?.orgId ?? null,
teamName: org?.name ?? '',
enabled:
Boolean(inline) &&
org !== null &&
rosterResult?.status === 'ok' &&
rosterResult.roster.viewerRole === 'owner',
onManageBilling: openPortal,
onDeleted: async () => {
await refreshAuth()
}
})
return (
<TeamPageView
org={org}
viewerUserId={cloud?.userId ?? null}
roster={rosterResult}
billing={billing.result}
arrival={arrival}
rowStates={actions.rowStates}
refreshing={userRefreshing && (roster.loading || billing.loading)}
billingBusy={billingBusy}
now={now}
create={{
organizations: (authStatus?.organizations ?? []).filter(
(candidate) => candidate.orgId !== orgId
),
creating,
error: createError,
unavailable: createUnavailable,
switchingOrgId
}}
onCreateTeam={createTeam}
onSwitchOrg={switchOrg}
onRefresh={() => {
setUserRefreshing(true)
quietRefresh()
window.setTimeout(() => setUserRefreshing(false), 600)
}}
onInvite={actions.invite}
onChangeRole={(member, role) => actions.changeRole(member, role)}
onMakeOwner={actions.makeOwner}
onRemove={actions.remove}
onResend={actions.resend}
onRevoke={(email) => {
const role =
rosterResult?.status === 'ok'
? (rosterResult.roster.pendingInvites.find((invite) => invite.email === email)?.role ??
'member')
: 'member'
actions.revoke(email, role)
}}
onLeave={leave}
onSeePlans={() => {
closeTeamPage()
openPlansPage('team')
}}
onManageBilling={openPortal}
onCreateWorkspace={() => {
closeTeamPage()
// The plan is for Orca Cloud, so the composer opens on it rather than this computer.
openModal('new-workspace-composer', {
initialEphemeralVmRecipeId: ORCA_CLOUD_RECIPE_ID,
telemetrySource: 'unknown'
})
}}
onOpenProfile={(userId) => {
// One dialog at a time: the profile replaces the Team page (as in Conductor 0.91).
closeTeamPage()
openTeammateProfile(userId)
}}
onDismissArrival={clearTeamPageArrival}
onContactSales={(url) => void window.api.shell.openUrl(url)}
inline={inline}
/>
<>
<TeamPageView
org={org}
viewerUserId={cloud?.userId ?? null}
roster={rosterResult}
billing={billing.result}
arrival={arrival}
rowStates={actions.rowStates}
refreshing={userRefreshing && (roster.loading || billing.loading)}
billingBusy={billingBusy}
now={now}
create={{
organizations: (authStatus?.organizations ?? []).filter(
(candidate) => candidate.orgId !== orgId
),
creating,
error: createError,
unavailable: createUnavailable,
switchingOrgId
}}
onCreateTeam={createTeam}
onSwitchOrg={switchOrg}
onRefresh={() => {
setUserRefreshing(true)
quietRefresh()
window.setTimeout(() => setUserRefreshing(false), 600)
}}
onInvite={actions.invite}
onChangeRole={(member, role) => actions.changeRole(member, role)}
onMakeOwner={actions.makeOwner}
onRemove={actions.remove}
onResend={actions.resend}
onRevoke={(email) => {
const role =
rosterResult?.status === 'ok'
? (rosterResult.roster.pendingInvites.find((invite) => invite.email === email)
?.role ?? 'member')
: 'member'
actions.revoke(email, role)
}}
onLeave={leave}
onSeePlans={() => {
closeTeamPage()
openPlansPage('team')
}}
onManageBilling={openPortal}
onCreateWorkspace={() => {
closeTeamPage()
// The plan is for Orca Cloud, so the composer opens on it rather than this computer.
openModal('new-workspace-composer', {
initialEphemeralVmRecipeId: ORCA_CLOUD_RECIPE_ID,
telemetrySource: 'unknown'
})
}}
onOpenProfile={(userId) => {
// One dialog at a time: the profile replaces the Team page (as in Conductor 0.91).
closeTeamPage()
openTeammateProfile(userId)
}}
onDismissArrival={clearTeamPageArrival}
onContactSales={(url) => void window.api.shell.openUrl(url)}
inline={inline}
dangerZone={
org && teamDelete.check ? (
<TeamDeleteSection
teamName={org.name}
check={teamDelete.check}
onDelete={teamDelete.open}
onBlockerAction={teamDelete.onBlockerAction}
/>
) : null
}
/>
{teamDelete.dialog}
</>
)
}
@@ -5,6 +5,7 @@ import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles'
import { sampleTeam } from '../plan-billing-fixtures'
import { TeamDialogContent } from './TeamDialogHost'
import { TeamPageView, type TeamPageViewProps } from './TeamPageView'
import { TeamDeleteSection, type TeamDeleteCheck } from './TeamDeleteSection'
// Fixtures render offline: a self-contained stand-in for a provider profile picture.
const ADA_PICTURE =
@@ -259,3 +260,60 @@ export const ssoAndLeave = defineFixture({
/>
)
})
// TEAM-DELETE: Settings › Team (inline), owner. The danger zone sits after "Leave <team>".
function DeletePage({ check }: { check: TeamDeleteCheck }): React.JSX.Element {
// Settings › Team renders the page inline, not in the dialog, so the whole page shows.
return (
<div className="max-w-2xl p-6">
<TeamPageView
{...base}
inline={{ onOpenPlanBilling: noop }}
dangerZone={
<TeamDeleteSection
teamName="Acme Engineering"
check={check}
onDelete={noop}
onBlockerAction={noop}
/>
}
/>
</div>
)
}
export const deleteTeamAllowed = defineFixture({
width,
render: () => <DeletePage check={{ state: 'ready', blockers: [] }} />
})
export const deleteTeamBlockedPlan = defineFixture({
width,
render: () => (
<DeletePage
check={{ state: 'ready', blockers: [{ code: 'subscription_active', status: 'active' }] }}
/>
)
})
export const deleteTeamBlockedOneWorkspace = defineFixture({
width,
render: () => (
<DeletePage check={{ state: 'ready', blockers: [{ code: 'workspaces_exist', count: 1 }] }} />
)
})
export const deleteTeamBlockedBoth = defineFixture({
width,
render: () => (
<DeletePage
check={{
state: 'ready',
blockers: [
{ code: 'subscription_active', status: 'trialing' },
{ code: 'workspaces_exist', count: 3 }
]
}}
/>
)
})
@@ -53,6 +53,8 @@ export type TeamPageViewProps = {
onContactSales: (url: string) => void
/** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */
inline?: { onOpenPlanBilling: () => void }
/** Owners in Settings › Team: "Delete <team>", after "Leave <team>" (TEAM-DELETE). */
dangerZone?: React.ReactNode
}
function viewerRoleSentence(role: OrcaOrgRole, t: Translate): string {
@@ -204,7 +206,7 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
className="rounded-sm text-foreground underline underline-offset-2 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={inline.onOpenPlanBilling}
>
{t('auto.components.billing.title', 'Plan & billing')}
{t('settings.accountBilling.title2', 'Account & billing')}
</button>
</p>
) : entitlement ? (
@@ -354,10 +356,15 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
'auto.components.billing.team.leaveBody2',
'You lose access to the team’s cloud workspaces and secrets. An owner or admin can invite you back.'
)
: t(
'auto.components.billing.team.leaveLastOwner',
'You’re the only owner. Make someone else an owner first.'
)}
: props.dangerZone
? t(
'auto.components.billing.team.leaveLastOwnerOrDelete',
'You’re the only owner. Make someone else an owner first, or delete the team.'
)
: t(
'auto.components.billing.team.leaveLastOwner',
'You’re the only owner. Make someone else an owner first.'
)}
</p>
</div>
<SettingsDisabledControlTooltip
@@ -383,6 +390,7 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
</SettingsDisabledControlTooltip>
</section>
) : null}
{props.dangerZone}
</div>
) : null}
</div>
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import type { Translate } from '@/i18n/use-translate'
import { teamDeleteBlockerLines, teamDeleteFailure } from './team-delete-copy'
import { teamDeleteCheckFrom } from './use-team-delete'
// Mirrors i18next's `_one` plural pick so both forms are checked.
const t: Translate = (_key, fallback, options) => {
const one = options?.count === 1 ? options.defaultValue_one : undefined
const text = typeof one === 'string' ? one : fallback
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name] ?? ''))
}
describe('TEAM-DELETE copy', () => {
it('names each blocker with its way out, plan first', () => {
const lines = teamDeleteBlockerLines(
[
{ code: 'workspaces_exist', count: 3 },
{ code: 'subscription_active', status: 'past_due' }
],
t
)
expect(lines.map((line) => [line.text, line.actionLabel])).toEqual([
[
'Your team still has a plan. Cancel it in billing first, then delete the team.',
'Manage billing'
],
['3 cloud workspaces still exist. Delete them first.', 'Show cloud workspaces']
])
expect(teamDeleteBlockerLines([{ code: 'workspaces_exist', count: 1 }], t)[0]?.text).toBe(
'1 cloud workspace still exists. Delete it first.'
)
})
it('keeps every failure in the dialog with its reason', () => {
expect(teamDeleteFailure({ status: 'deleted' }, t)).toBeNull()
expect(teamDeleteFailure({ status: 'confirm-mismatch' }, t)).toEqual({
kind: 'mismatch',
text: 'The team name doesn’t match.'
})
expect(teamDeleteFailure({ status: 'unavailable' }, t)).toEqual({
kind: 'message',
text: 'Couldn’t check the team right now, so nothing was deleted. Try again.'
})
expect(teamDeleteFailure({ status: 'rate-limited' }, t)).toEqual({
kind: 'message',
text: 'Too many attempts. Wait a minute, then try again.'
})
const blockers = [{ code: 'workspaces_exist' as const, count: 2 }]
expect(teamDeleteFailure({ status: 'blocked', blockers }, t)).toEqual({
kind: 'blocked',
blockers
})
})
it('pre-check: hides the card when auth has no route or says not an owner', () => {
expect(teamDeleteCheckFrom({ status: 'unsupported' })).toBeNull()
expect(teamDeleteCheckFrom({ status: 'forbidden' })).toBeNull()
// A failed check still offers the delete; auth checks again at submit.
expect(teamDeleteCheckFrom({ status: 'unavailable' })).toEqual({ state: 'ready', blockers: [] })
expect(
teamDeleteCheckFrom({
status: 'ok',
deletable: false,
blockers: [{ code: 'subscription_active', status: 'active' }]
})
).toEqual({ state: 'ready', blockers: [{ code: 'subscription_active', status: 'active' }] })
})
})
@@ -0,0 +1,113 @@
// Words for deleting a team (TEAM-DELETE, design-squad). The body lists only what the delete
// really does: workspaces are never deleted by it, so any workspace blocks it instead.
import type { Translate } from '@/i18n/use-translate'
import type {
OrcaOrgDeleteBlocker,
OrcaProfileOrgDeleteResult
} from '../../../../../shared/orca-org-delete'
export type TeamDeleteBlockerLine = {
code: OrcaOrgDeleteBlocker['code']
text: string
actionLabel: string
}
export function teamDeleteBlockerLines(
blockers: readonly OrcaOrgDeleteBlocker[],
t: Translate
): TeamDeleteBlockerLine[] {
// The plan line first: it usually takes longest to clear.
const ordered = [...blockers].sort((a, b) =>
a.code === b.code ? 0 : a.code === 'subscription_active' ? -1 : 1
)
return ordered.map((blocker) =>
blocker.code === 'subscription_active'
? {
code: blocker.code,
text: t(
'auto.components.billing.team.delete.blockedPlan',
'Your team still has a plan. Cancel it in billing first, then delete the team.'
),
actionLabel: t('auto.components.billing.manageBilling', 'Manage billing')
}
: {
code: blocker.code,
text: t(
'auto.components.billing.team.delete.blockedWorkspaces',
'{{count}} cloud workspaces still exist. Delete them first.',
{
count: blocker.count,
defaultValue_one: '{{count}} cloud workspace still exists. Delete it first.'
}
),
actionLabel: t(
'auto.components.billing.team.delete.showWorkspaces',
'Show cloud workspaces'
)
}
)
}
export type TeamDeleteDialogError =
/** Shown under the name field. */
| { kind: 'mismatch'; text: string }
/** Shown above the buttons; `blockers` repeats the card's lines (with their actions). */
| { kind: 'blocked'; blockers: OrcaOrgDeleteBlocker[] }
| { kind: 'message'; text: string }
/** Null for `deleted`; every failure keeps the dialog open with its reason. */
export function teamDeleteFailure(
result: OrcaProfileOrgDeleteResult,
t: Translate
): TeamDeleteDialogError | null {
switch (result.status) {
case 'deleted':
return null
case 'blocked':
return result.blockers.length > 0
? { kind: 'blocked', blockers: result.blockers }
: { kind: 'message', text: genericFailure(t) }
case 'confirm-mismatch':
return {
kind: 'mismatch',
text: t('auto.components.billing.team.delete.mismatch', 'The team name doesn’t match.')
}
case 'unavailable':
return {
kind: 'message',
text: t(
'auto.components.billing.team.delete.unavailable',
'Couldn’t check the team right now, so nothing was deleted. Try again.'
)
}
case 'rate-limited':
return {
kind: 'message',
text: t(
'auto.components.billing.team.delete.rateLimited',
'Too many attempts. Wait a minute, then try again.'
)
}
case 'forbidden':
return {
kind: 'message',
text: t('auto.components.billing.team.delete.forbidden', 'Only owners can delete the team.')
}
case 'reconnect-required':
return {
kind: 'message',
text: t(
'auto.components.billing.openSignedOut2',
'Sign in to Orca again to manage your team.'
)
}
case 'unsupported':
case 'unconfigured':
case 'failed':
return { kind: 'message', text: genericFailure(t) }
}
}
function genericFailure(t: Translate): string {
return t('auto.components.billing.team.delete.failed', 'Couldn’t delete the team. Try again.')
}
@@ -0,0 +1,203 @@
// Deleting a team from Settings › Team (TEAM-DELETE): the card's pre-check, the typed-name
// dialog, and what happens after. Auth decides; this only explains its answer.
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Dialog } from '@/components/ui/dialog'
import { useTranslate } from '@/i18n/use-translate'
import type {
OrcaOrgDeleteBlocker,
OrcaProfileOrgDeleteCheckResult,
OrcaProfileOrgDeleteResult
} from '../../../../../shared/orca-org-delete'
import { openCloudWorkspacesHome } from '../../cloud-workspaces/cloud-workspaces-home-nav'
import { TeamDeleteDialogView } from './TeamDeleteDialog'
import type { TeamDeleteCheck } from './TeamDeleteSection'
import { teamDeleteFailure, type TeamDeleteDialogError } from './team-delete-copy'
const RATE_LIMIT_WAIT_MS = 60_000
/** The card's state from the pre-check; null hides the card (not an owner, or no route yet). */
export function teamDeleteCheckFrom(
result: OrcaProfileOrgDeleteCheckResult
): TeamDeleteCheck | null {
switch (result.status) {
case 'ok':
return { state: 'ready', blockers: result.blockers }
case 'unsupported':
case 'forbidden':
case 'unconfigured':
return null
case 'unavailable':
case 'rate-limited':
case 'reconnect-required':
case 'failed':
// Couldn't check: offer the delete anyway; auth checks again and the dialog explains.
return { state: 'ready', blockers: [] }
}
}
/** Home with every team workspace, and the Archived switch on when any are archived. */
async function showTeamWorkspaces(): Promise<void> {
const archived = await window.api.cloudWorkspaces
.list({ scope: 'team', archived: 'only' })
.then((result) => result.ok && result.value.length > 0)
.catch(() => false)
openCloudWorkspacesHome({ scope: 'team', archived })
}
export function useTeamDelete({
orgId,
teamName,
enabled,
onManageBilling,
onDeleted
}: {
orgId: string | null
teamName: string
/** Owners only. */
enabled: boolean
onManageBilling: () => void
onDeleted: () => Promise<void>
}): {
check: TeamDeleteCheck | null
open: () => void
dialog: React.JSX.Element | null
onBlockerAction: (code: OrcaOrgDeleteBlocker['code']) => void
} {
const t = useTranslate()
const [check, setCheck] = useState<TeamDeleteCheck | null>(null)
const [open, setOpen] = useState(false)
const [typed, setTyped] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<TeamDeleteDialogError | null>(null)
const [waiting, setWaiting] = useState(false)
const waitTimer = useRef<number | null>(null)
const runCheck = useCallback(() => {
if (!enabled || !orgId) {
setCheck(null)
return () => {}
}
let cancelled = false
setCheck((current) => current ?? { state: 'checking' })
void window.api.orcaProfiles
.orgDeleteCheck({ orgId })
.catch((reason: unknown): OrcaProfileOrgDeleteCheckResult => ({
status: 'failed',
error: String(reason)
}))
.then((result) => {
if (!cancelled) {
setCheck(teamDeleteCheckFrom(result))
}
})
return () => {
cancelled = true
}
}, [enabled, orgId])
// Checked when the card shows, and again on return (a plan cancelled or workspaces deleted elsewhere).
useEffect(() => {
let cancel = runCheck()
const onFocus = (): void => {
cancel()
cancel = runCheck()
}
window.addEventListener('focus', onFocus)
return () => {
cancel()
window.removeEventListener('focus', onFocus)
}
}, [runCheck])
useEffect(
() => () => {
if (waitTimer.current !== null) {
window.clearTimeout(waitTimer.current)
}
},
[]
)
const close = (): void => {
setOpen(false)
setTyped('')
setError(null)
}
const onBlockerAction = (code: OrcaOrgDeleteBlocker['code']): void => {
close()
if (code === 'subscription_active') {
onManageBilling()
} else {
void showTeamWorkspaces()
}
}
const submit = (): void => {
if (!orgId || submitting || waiting || typed !== teamName) {
return
}
setSubmitting(true)
setError(null)
void window.api.orcaProfiles
.orgDelete({ orgId, confirmName: typed })
.catch((reason: unknown): OrcaProfileOrgDeleteResult => ({
status: 'failed',
error: String(reason)
}))
.then(async (result) => {
setSubmitting(false)
if (result.status === 'deleted') {
close()
toast.success(
t('auto.components.billing.team.delete.deletedToast2', '{{value0}} was deleted', {
value0: teamName
})
)
await onDeleted()
return
}
if (result.status === 'blocked') {
setCheck({ state: 'ready', blockers: result.blockers })
}
if (result.status === 'rate-limited') {
setWaiting(true)
waitTimer.current = window.setTimeout(() => setWaiting(false), RATE_LIMIT_WAIT_MS)
}
setError(teamDeleteFailure(result, t))
})
}
const dialog = enabled ? (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next && !submitting) {
close()
}
}}
>
{open ? (
<TeamDeleteDialogView
teamName={teamName}
typed={typed}
submitting={submitting}
error={error}
waiting={waiting}
onTypedChange={(value) => {
setTyped(value)
if (error?.kind === 'mismatch') {
setError(null)
}
}}
onCancel={close}
onSubmit={submit}
onBlockerAction={onBlockerAction}
/>
) : null}
</Dialog>
) : null
return { check, open: () => setOpen(true), dialog, onBlockerAction }
}
+4
View File
@@ -238,6 +238,10 @@
"seatsMonthly_one": "{{count}} seat · Monthly",
"seatsMonthly_other": "{{count}} seats · Monthly",
"team": {
"delete": {
"blockedWorkspaces_one": "{{count}} cloud workspace still exists. Delete it first.",
"blockedWorkspaces_other": "{{count}} cloud workspaces still exist. Delete them first."
},
"invitedCount_one": "{{count}} invited",
"invitedCount_other": "{{count}} invited",
"memberCount_one": "{{count}} member",
+20 -1
View File
@@ -21201,7 +21201,26 @@
"totalPerYear": "{{total}} per year",
"openProfile": "Open {{value0}}’s profile",
"viewProfile": "View profile",
"seatsOnPlanBilling": "Seats and invoices are in"
"seatsOnPlanBilling": "Seats and invoices are in",
"delete": {
"confirmTitle": "Delete {{value0}}?",
"confirmBody": "Everyone loses access to the team. Pending invites are cancelled. Its settings and team cloud secrets are deleted. Billing history is kept. This can’t be undone.",
"typeToConfirm": "Type {{value0}} to confirm",
"cta": "Delete team",
"title": "Delete {{value0}}",
"cardBody": "Removes the team for everyone. This can’t be undone.",
"blockedPlan": "Your team still has a plan. Cancel it in billing first, then delete the team.",
"blockedWorkspaces_one": "{{count}} cloud workspace still exists. Delete it first.",
"blockedWorkspaces_other": "{{count}} cloud workspaces still exist. Delete them first.",
"showWorkspaces": "Show cloud workspaces",
"mismatch": "The team name doesn’t match.",
"unavailable": "Couldn’t check the team right now, so nothing was deleted. Try again.",
"rateLimited": "Too many attempts. Wait a minute, then try again.",
"forbidden": "Only owners can delete the team.",
"failed": "Couldn’t delete the team. Try again.",
"deletedToast2": "{{value0}} was deleted"
},
"leaveLastOwnerOrDelete": "You’re the only owner. Make someone else an owner first, or delete the team."
},
"manageBilling": "Manage billing",
"comparePlans": "Compare plans",
@@ -73,6 +73,8 @@ export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
orgInviteResend: async () => ({ status: 'unconfigured' }),
orgLeave: async () => ({ status: 'unconfigured' }),
orgCreate: async () => ({ status: 'unconfigured' }),
orgDeleteCheck: async () => ({ status: 'unconfigured' }),
orgDelete: async () => ({ status: 'unconfigured' }),
onBillingReturn: () => () => {},
onBillingRefreshRequested: () => () => {},
onCloudSetupReturn: () => () => {},
+74
View File
@@ -0,0 +1,74 @@
// Deleting a team (TEAM-DELETE): auth's GET /v1/desktop/orgs/:orgId/delete-check and
// POST /v1/desktop/orgs/:orgId/delete. Owners only; auth refuses while the team still has a plan
// or any cloud workspace (running, asleep or archived). The delete never removes workspaces.
/** Why the team can't be deleted yet. Unknown codes from a newer auth are dropped. */
export type OrcaOrgDeleteBlocker =
/** Any open subscription status (active, trialing, past_due, unpaid, incomplete, paused). */
| { code: 'subscription_active'; status: string }
/** Cloud workspaces in any state but destroyed. */
| { code: 'workspaces_exist'; count: number }
export type OrcaProfileOrgDeleteCheckArgs = { orgId: string }
/** `confirmName` is the team name exactly as typed; auth compares it case-sensitively. */
export type OrcaProfileOrgDeleteArgs = { orgId: string; confirmName: string }
/**
* `unavailable`: billing or sandbox-control couldn't be reached (503); nothing was checked or
* deleted. `unsupported`: this auth deployment has no delete route yet.
*/
type OrcaOrgDeleteCommonStatus =
| 'unconfigured'
| 'reconnect-required'
| 'forbidden'
| 'unsupported'
| 'rate-limited'
| 'unavailable'
export type OrcaProfileOrgDeleteCheckResult =
| { status: 'ok'; deletable: boolean; blockers: OrcaOrgDeleteBlocker[] }
| { status: OrcaOrgDeleteCommonStatus }
| { status: 'failed'; error: string }
export type OrcaProfileOrgDeleteResult =
| { status: 'deleted' }
/** 409 not_deletable: a blocker the pre-check missed. */
| { status: 'blocked'; blockers: OrcaOrgDeleteBlocker[] }
/** 400 confirm_mismatch. */
| { status: 'confirm-mismatch' }
| { status: OrcaOrgDeleteCommonStatus }
| { status: 'failed'; error: string }
export const ORCA_ORG_DELETE_CONFIRM_MAX_LENGTH = 256
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Keeps the blockers this desktop knows how to explain. */
export function normalizeOrgDeleteBlockers(value: unknown): OrcaOrgDeleteBlocker[] {
if (!Array.isArray(value)) {
return []
}
const blockers: OrcaOrgDeleteBlocker[] = []
for (const entry of value) {
if (!isPlainRecord(entry)) {
continue
}
const record = entry
if (record.code === 'subscription_active') {
blockers.push({
code: 'subscription_active',
status: typeof record.status === 'string' ? record.status : 'active'
})
} else if (record.code === 'workspaces_exist') {
const count =
typeof record.count === 'number' && Number.isFinite(record.count)
? Math.max(1, Math.floor(record.count))
: 1
blockers.push({ code: 'workspaces_exist', count })
}
}
return blockers
}
@@ -88,6 +88,22 @@
"auto.components.automations.external.automation.schedule.display.a8e92b815a",
"auto.components.automations.externalDisplay.local",
"auto.components.automations.externalDisplay.sshHost",
"auto.components.billing.manageBilling",
"auto.components.billing.openSignedOut2",
"auto.components.billing.team.delete.blockedPlan",
"auto.components.billing.team.delete.blockedWorkspaces",
"auto.components.billing.team.delete.cardBody",
"auto.components.billing.team.delete.confirmBody",
"auto.components.billing.team.delete.confirmTitle",
"auto.components.billing.team.delete.cta",
"auto.components.billing.team.delete.failed",
"auto.components.billing.team.delete.forbidden",
"auto.components.billing.team.delete.mismatch",
"auto.components.billing.team.delete.rateLimited",
"auto.components.billing.team.delete.showWorkspaces",
"auto.components.billing.team.delete.title",
"auto.components.billing.team.delete.typeToConfirm",
"auto.components.billing.team.delete.unavailable",
"auto.components.browserPane.sitePermission.allow",
"auto.components.browserPane.sitePermission.ask.camera",
"auto.components.browserPane.sitePermission.ask.clipboard",
@@ -111,6 +127,7 @@
"auto.components.browserPane.sitePermission.label.windowManagement",
"auto.components.browserPane.sitePermission.localFiles",
"auto.components.browserPane.sitePermission.title",
"auto.components.confirmation.dialog.56f5c60e0c",
"auto.components.confirmation.dialog.92bac3217e",
"auto.components.editor.CombinedDiffViewer.0f806a2ab1",
"auto.components.editor.CombinedDiffViewer.1da745c551",