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.' + )} + + +
{ + event.preventDefault() + if (canSubmit) { + props.onSubmit() + } + }} + > + + props.onTypedChange(event.target.value)} + /> + {mismatch ? ( +

+ {mismatch} +

+ ) : null} +
+ {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 ? ( + { + if (!next && !submitting) { + close() + } + }} + > + {open ? ( + { + setTyped(value) + if (error?.kind === 'mismatch') { + setError(null) + } + }} + onCancel={close} + onSubmit={submit} + onBlockerAction={onBlockerAction} + /> + ) : null} + + ) : 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",