diff --git a/src/main/ipc/profiles/orca-profile-org-members-handlers.ts b/src/main/ipc/profiles/orca-profile-org-members-handlers.ts
index d259e7a5e7e..f8dc1c80713 100644
--- a/src/main/ipc/profiles/orca-profile-org-members-handlers.ts
+++ b/src/main/ipc/profiles/orca-profile-org-members-handlers.ts
@@ -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 } {
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 =>
+ checkOrcaProfileOrgDelete(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId)
+ )
+
+ ipcMain.handle(
+ 'orcaProfiles:orgDelete',
+ async (_event, rawArgs: unknown): Promise => {
+ const { orgId, record } = orgMembersScopedArgs(rawArgs)
+ return deleteOrcaProfileOrg(getProfileUserDataPath(), {
+ orgId,
+ confirmName: orgDeleteConfirmNameFromUnknown(record.confirmName)
+ })
+ }
+ )
+
ipcMain.handle(
'orcaProfiles:orgInviteResend',
async (_event, rawArgs: unknown): Promise =>
diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-client.test.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-client.test.ts
new file mode 100644
index 00000000000..8b062c2ec3c
--- /dev/null
+++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-client.test.ts
@@ -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'
+ })
+ })
+})
diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-client.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-client.ts
new file mode 100644
index 00000000000..ea0f2502f77
--- /dev/null
+++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-client.ts
@@ -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> {
+ try {
+ const body: unknown = await response.json()
+ return isPlainRecord(body) ? body : {}
+ } catch {
+ return {}
+ }
+}
+
+export function parseOrgDeleteCheck(body: Record): 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 {
+ 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 {
+ 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' }
+}
diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-service.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-service.ts
new file mode 100644
index 00000000000..0e65797a227
--- /dev/null
+++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-delete-service.ts
@@ -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 {
+ 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 {
+ 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 }
+ }
+}
diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts
index 38964e5ece2..4c34ef96bee 100644
--- a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts
+++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-client.ts
@@ -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 {
+export async function extractErrorCode(response: Response): Promise {
try {
const body = (await response.json()) as unknown
if (
@@ -135,7 +135,11 @@ async function extractErrorCode(response: Response): Promise
}
// 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: {
diff --git a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts
index 36b8119cfde..f886639940b 100644
--- a/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts
+++ b/src/main/orca-profiles/cloud-org/profile-cloud-org-members-service.ts
@@ -40,14 +40,14 @@ import {
revokeDevOrcaCloudOrgInvite
} from './profile-cloud-dev-org-members'
-type OrgCallResult =
+export type OrgCallResult =
| { 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(
+export async function runOrgMemberCall(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts
index e9982e3fcae..e9b061d58d9 100644
--- a/src/preload/api/orca-profile-api.ts
+++ b/src/preload/api/orca-profile-api.ts
@@ -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
/** Creates a team owned by the signed-in user. */
orgCreate: (args: OrcaProfileOrgCreateArgs) => Promise
+ /** Whether an owner can delete the team now, and what blocks it (TEAM-DELETE). */
+ orgDeleteCheck: (args: OrcaProfileOrgDeleteCheckArgs) => Promise
+ /** Deletes the team for everyone; `confirmName` is the name exactly as typed. */
+ orgDelete: (args: OrcaProfileOrgDeleteArgs) => Promise
/** 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). */
diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts
index 5f1e27f372f..497069b5105 100644
--- a/src/preload/api/orca-profiles-bridge.ts
+++ b/src/preload/api/orca-profiles-bridge.ts
@@ -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)
diff --git a/src/renderer/src/components/billing/team/TeamDeleteDialog.fixture.tsx b/src/renderer/src/components/billing/team/TeamDeleteDialog.fixture.tsx
new file mode 100644
index 00000000000..282a76e39fd
--- /dev/null
+++ b/src/renderer/src/components/billing/team/TeamDeleteDialog.fixture.tsx
@@ -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): React.JSX.Element {
+ return (
+
+ )
+}
+
+export const empty = defineFixture({ width: 720, render: () => })
+
+export const typed = defineFixture({ width: 720, render: () => })
+
+export const mismatch = defineFixture({
+ width: 720,
+ render: () => (
+
+ )
+})
+
+export const unavailable = defineFixture({
+ width: 720,
+ render: () => (
+
+ )
+})
+
+export const rateLimited = defineFixture({
+ width: 720,
+ render: () => (
+
+ )
+})
+
+export const blockedAtSubmit = defineFixture({
+ width: 720,
+ render: () => (
+
+ )
+})
diff --git a/src/renderer/src/components/billing/team/TeamDeleteDialog.tsx b/src/renderer/src/components/billing/team/TeamDeleteDialog.tsx
new file mode 100644
index 00000000000..891cf8c3644
--- /dev/null
+++ b/src/renderer/src/components/billing/team/TeamDeleteDialog.tsx
@@ -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 ?": 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 (
+ {
+ if (props.submitting) {
+ event.preventDefault()
+ }
+ }}
+ >
+
+
+ {t('auto.components.billing.team.delete.confirmTitle', 'Delete {{value0}}?', {
+ value0: props.teamName
+ })}
+
+
+ {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.'
+ )}
+
+
+
+ {props.error?.kind === 'blocked' ? (
+
+
+
+ ) : props.error?.kind === 'message' ? (
+
+ {props.error.text}
+
+ ) : null}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/billing/team/TeamDeleteSection.tsx b/src/renderer/src/components/billing/team/TeamDeleteSection.tsx
new file mode 100644
index 00000000000..79e58089ece
--- /dev/null
+++ b/src/renderer/src/components/billing/team/TeamDeleteSection.tsx
@@ -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 (
+
+ {teamDeleteBlockerLines(blockers, t).map((line) => (
+ -
+ {line.text}{' '}
+
+
+ ))}
+
+ )
+}
+
+/** Settings › Team danger zone (owners only): "Delete ", styled like "Leave ". */
+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 (
+
+
+
+ {t('auto.components.billing.team.delete.title', 'Delete {{value0}}', {
+ value0: teamName
+ })}
+
+
+ {t(
+ 'auto.components.billing.team.delete.cardBody',
+ 'Removes the team for everyone. This can’t be undone.'
+ )}
+
+ {blocked ?
: null}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx
index 9ea1576480c..8b17fb6737d 100644
--- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx
+++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx
@@ -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 (
- 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}
- />
+ <>
+ 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 ? (
+
+ ) : null
+ }
+ />
+ {teamDelete.dialog}
+ >
)
}
diff --git a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx
index 99e0cc4ac51..4bf0fcf7a5b 100644
--- a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx
+++ b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx
@@ -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 ".
+function DeletePage({ check }: { check: TeamDeleteCheck }): React.JSX.Element {
+ // Settings › Team renders the page inline, not in the dialog, so the whole page shows.
+ return (
+
+
+ }
+ />
+
+ )
+}
+
+export const deleteTeamAllowed = defineFixture({
+ width,
+ render: () =>
+})
+
+export const deleteTeamBlockedPlan = defineFixture({
+ width,
+ render: () => (
+
+ )
+})
+
+export const deleteTeamBlockedOneWorkspace = defineFixture({
+ width,
+ render: () => (
+
+ )
+})
+
+export const deleteTeamBlockedBoth = defineFixture({
+ width,
+ render: () => (
+
+ )
+})
diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx
index 427e69d2004..97657a732e3 100644
--- a/src/renderer/src/components/billing/team/TeamPageView.tsx
+++ b/src/renderer/src/components/billing/team/TeamPageView.tsx
@@ -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 ", after "Leave " (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')}
) : 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.'
+ )}
) : null}
+ {props.dangerZone}
) : null}
diff --git a/src/renderer/src/components/billing/team/team-delete-copy.test.ts b/src/renderer/src/components/billing/team/team-delete-copy.test.ts
new file mode 100644
index 00000000000..c7a6e3003b9
--- /dev/null
+++ b/src/renderer/src/components/billing/team/team-delete-copy.test.ts
@@ -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' }] })
+ })
+})
diff --git a/src/renderer/src/components/billing/team/team-delete-copy.ts b/src/renderer/src/components/billing/team/team-delete-copy.ts
new file mode 100644
index 00000000000..5805eb4941d
--- /dev/null
+++ b/src/renderer/src/components/billing/team/team-delete-copy.ts
@@ -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.')
+}
diff --git a/src/renderer/src/components/billing/team/use-team-delete.tsx b/src/renderer/src/components/billing/team/use-team-delete.tsx
new file mode 100644
index 00000000000..9f7b8da0bb1
--- /dev/null
+++ b/src/renderer/src/components/billing/team/use-team-delete.tsx
@@ -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 {
+ 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
+}): {
+ check: TeamDeleteCheck | null
+ open: () => void
+ dialog: React.JSX.Element | null
+ onBlockerAction: (code: OrcaOrgDeleteBlocker['code']) => void
+} {
+ const t = useTranslate()
+ const [check, setCheck] = useState(null)
+ const [open, setOpen] = useState(false)
+ const [typed, setTyped] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+ const [waiting, setWaiting] = useState(false)
+ const waitTimer = useRef(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 ? (
+
+ ) : null
+
+ return { check, open: () => setOpen(true), dialog, onBlockerAction }
+}
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index ab954ffe47f..3768b31963e 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 95aef8a190c..0022e7beb3f 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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",
diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts
index fdf42c15616..b6c17f986d6 100644
--- a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts
+++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts
@@ -73,6 +73,8 @@ export function createWebOrcaProfilesApi(): Partial {
orgInviteResend: async () => ({ status: 'unconfigured' }),
orgLeave: async () => ({ status: 'unconfigured' }),
orgCreate: async () => ({ status: 'unconfigured' }),
+ orgDeleteCheck: async () => ({ status: 'unconfigured' }),
+ orgDelete: async () => ({ status: 'unconfigured' }),
onBillingReturn: () => () => {},
onBillingRefreshRequested: () => () => {},
onCloudSetupReturn: () => () => {},
diff --git a/src/shared/orca-org-delete.ts b/src/shared/orca-org-delete.ts
new file mode 100644
index 00000000000..e8cf8056566
--- /dev/null
+++ b/src/shared/orca-org-delete.ts
@@ -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 {
+ 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
+}
diff --git a/src/shared/plugins/languages/protected-copy-keys.json b/src/shared/plugins/languages/protected-copy-keys.json
index 53fb08b5b6f..f2fad793162 100644
--- a/src/shared/plugins/languages/protected-copy-keys.json
+++ b/src/shared/plugins/languages/protected-copy-keys.json
@@ -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",