Skip to content

Commit 1678089

Browse files
committed
2 parents 8e6edc2 + 130f8f6 commit 1678089

8 files changed

Lines changed: 472 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Studio: cascade-delete projects and organizations** — The previously-disabled "Archive project" button on `/projects/$projectId` is now an enabled "Delete project" action with typed-name confirmation. New "Danger zone" section on `/orgs/$orgId` lets owners delete an organization, which cascades to every project the org owns (including each project's physical database). Server side adds `DELETE /api/v1/cloud/projects/:id[?force=1]` and `DELETE /api/v1/cloud/organizations/:id` to `HttpDispatcher`, both routed via `dispatcher-plugin`. The org-delete path uses better-auth's `auth.api.deleteOrganization` (which removes members + invitations + teams) and falls back to a direct `sys_organization` row delete when the plugin isn't loaded. Client SDK gains `client.projects.delete(id, { force })` and `client.organizations.delete(id)`. New Studio hooks `useDeleteProject` and `useDeleteOrganization` (the latter refreshes the session + org list so the active-org pointer is cleared automatically).
1112
- **`os auth login` — browser-based device flow (Vercel CLI style)** — Running `os auth login` in an interactive TTY no longer requires typing a password into the terminal. The CLI now calls `POST /api/v1/auth/device/request` to obtain a one-time device code, prints the verification URL, auto-opens the browser, and polls `GET /api/v1/auth/device/token` every 2 s until the user approves. A new Studio page at `/_studio/auth/device?code=…` lets authenticated users (or users who sign in inline) approve the request with one click. The old `--email`/`--password` path is preserved for non-interactive / CI use; `--no-browser` skips auto-open. Server-side: two new endpoints (`/device/request`, `/device/token`) and an approval endpoint (`/device/approve`) added to `plugin-auth`; device codes expire after 5 min and are stored in-memory.
1213
- **`os auth register` CLI command** — New `os auth register` command creates an account and stores credentials in one step, with interactive prompts (email, name, password) and `--email`/`--name`/`--password`/`--url` flags for non-interactive use.
1314
- **`os auth login` — already-logged-in guard** — If a valid token already exists in `~/.objectstack/credentials.json`, `os auth login` now prints "Already logged in as \<email\>" and exits 0. Use `os auth logout` first to switch accounts, or pass `--force` to bypass the check.

apps/studio/src/hooks/useProjects.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,42 @@ export function useRetryProvisioning() {
337337

338338
return { retry, retrying, error };
339339
}
340+
341+
/**
342+
* Hook: cascade-delete a project (clears credential / member / package
343+
* installation rows, releases the physical DB, then drops `sys_project`).
344+
*
345+
* Wraps `client.projects.delete(id, { force })`.
346+
*/
347+
export function useDeleteProject() {
348+
const client = useClient() as any;
349+
const [deleting, setDeleting] = useState(false);
350+
const [error, setError] = useState<Error | null>(null);
351+
352+
const remove = useCallback(
353+
async (projectId: string, opts?: { force?: boolean }) => {
354+
if (!client?.projects?.delete) {
355+
throw new Error('Client not ready');
356+
}
357+
setDeleting(true);
358+
setError(null);
359+
try {
360+
const result = await client.projects.delete(projectId, opts);
361+
// Forget the active-project pointer if it was this one.
362+
if (recallActiveProject() === projectId) {
363+
rememberActiveProject(null);
364+
client?.setProjectId?.(undefined);
365+
}
366+
return result;
367+
} catch (err) {
368+
setError(err as Error);
369+
throw err;
370+
} finally {
371+
setDeleting(false);
372+
}
373+
},
374+
[client],
375+
);
376+
377+
return { remove, deleting, error };
378+
}

apps/studio/src/hooks/useSession.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,44 @@ export function useCreateOrganization() {
247247

248248
return { create, creating, error };
249249
}
250+
251+
/**
252+
* Hook: cascade-delete an organization.
253+
*
254+
* Wraps `client.organizations.delete(id)` (which hits
255+
* `DELETE /api/v1/cloud/organizations/:id`). The server tears down every
256+
* project owned by the organization (including each project's physical
257+
* database) before dropping the org row itself.
258+
*
259+
* On success the local session + organization list are refreshed so the
260+
* deleted org disappears from the switcher and `activeOrganizationId`
261+
* gets cleared if it pointed at this org.
262+
*/
263+
export function useDeleteOrganization() {
264+
const client = useClient() as any;
265+
const { reloadOrganizations, refresh } = useSession();
266+
const [deleting, setDeleting] = useState(false);
267+
const [error, setError] = useState<Error | null>(null);
268+
269+
const remove = useCallback(
270+
async (organizationId: string) => {
271+
if (!client?.organizations?.delete) throw new Error('Client not ready');
272+
setDeleting(true);
273+
setError(null);
274+
try {
275+
const result = await client.organizations.delete(organizationId);
276+
await reloadOrganizations();
277+
await refresh();
278+
return result;
279+
} catch (err) {
280+
setError(err as Error);
281+
throw err;
282+
} finally {
283+
setDeleting(false);
284+
}
285+
},
286+
[client, reloadOrganizations, refresh],
287+
);
288+
289+
return { remove, deleting, error };
290+
}

apps/studio/src/routes/orgs.$orgId.tsx

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
3232
import { Badge } from '@/components/ui/badge';
3333
import { toast } from '@/hooks/use-toast';
34-
import { useOrganizations, useSession } from '@/hooks/useSession';
34+
import { useOrganizations, useSession, useDeleteOrganization } from '@/hooks/useSession';
3535
import {
3636
useOrganizationMembers,
3737
useOrganizationInvitations,
@@ -64,10 +64,14 @@ function OrgDetailPage() {
6464

6565
const { apps, loading: loadingApps } = useOrgApps(orgId);
6666

67+
const { remove: deleteOrganization, deleting: deletingOrg } = useDeleteOrganization();
68+
6769
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
6870
const [inviteEmail, setInviteEmail] = useState('');
6971
const [inviteRole, setInviteRole] = useState('member');
7072
const [inviting, setInviting] = useState(false);
73+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
74+
const [deleteConfirmText, setDeleteConfirmText] = useState('');
7175

7276
const handleSetActive = async () => {
7377
try {
@@ -144,6 +148,39 @@ function OrgDetailPage() {
144148
}
145149
};
146150

151+
const handleDeleteOrganization = async () => {
152+
if (!org) return;
153+
if (deleteConfirmText !== org.name) {
154+
toast({
155+
title: 'Confirmation does not match',
156+
description: `Type "${org.name}" to confirm deletion.`,
157+
variant: 'destructive',
158+
});
159+
return;
160+
}
161+
try {
162+
const result = await deleteOrganization(orgId);
163+
const warnings = (result as any)?.warnings as string[] | undefined;
164+
const deletedProjects = (result as any)?.deletedProjects ?? 0;
165+
toast({
166+
title: 'Organization deleted',
167+
description: warnings?.length
168+
? `Removed ${deletedProjects} project(s). Warnings: ${warnings[0]}${warnings.length > 1 ? ` (+${warnings.length - 1} more)` : ''}`
169+
: `${org.name} and ${deletedProjects} project(s) (with their databases) have been removed.`,
170+
variant: warnings?.length ? 'destructive' : undefined,
171+
});
172+
setDeleteDialogOpen(false);
173+
setDeleteConfirmText('');
174+
navigate({ to: '/orgs' });
175+
} catch (err) {
176+
toast({
177+
title: 'Failed to delete organization',
178+
description: (err as Error).message,
179+
variant: 'destructive',
180+
});
181+
}
182+
};
183+
147184
const isActive = session?.activeOrganizationId === orgId;
148185
const pendingInvitations = invitations.filter((i) => i.status === 'pending');
149186

@@ -422,9 +459,81 @@ function OrgDetailPage() {
422459
Go to projects
423460
</Link>
424461
</p>
462+
463+
{/* Danger zone — cascade-delete the organization. */}
464+
<Card className="border-destructive/40">
465+
<CardHeader>
466+
<CardTitle className="text-base text-destructive">Danger zone</CardTitle>
467+
<CardDescription>
468+
Permanently delete this organization, all of its projects, and every
469+
project's underlying database. This action cannot be undone.
470+
</CardDescription>
471+
</CardHeader>
472+
<CardContent>
473+
<Button
474+
variant="destructive"
475+
size="sm"
476+
onClick={() => setDeleteDialogOpen(true)}
477+
disabled={deletingOrg}
478+
>
479+
<Trash2 className="mr-2 h-4 w-4" />
480+
Delete organization
481+
</Button>
482+
</CardContent>
483+
</Card>
425484
</div>
426485
</div>
427486

487+
{/* Delete Organization Dialog */}
488+
<Dialog
489+
open={deleteDialogOpen}
490+
onOpenChange={(open) => {
491+
setDeleteDialogOpen(open);
492+
if (!open) setDeleteConfirmText('');
493+
}}
494+
>
495+
<DialogContent>
496+
<DialogHeader>
497+
<DialogTitle className="text-destructive">Delete organization</DialogTitle>
498+
<DialogDescription>
499+
This will permanently delete <strong>{org?.name}</strong>, all of its
500+
projects, and every project's underlying database. Members and pending
501+
invitations will be removed. This action cannot be undone.
502+
</DialogDescription>
503+
</DialogHeader>
504+
<div className="space-y-4 py-2">
505+
<div className="space-y-2">
506+
<Label htmlFor="delete-confirm">
507+
Type <code className="font-mono text-xs">{org?.name}</code> to confirm
508+
</Label>
509+
<Input
510+
id="delete-confirm"
511+
value={deleteConfirmText}
512+
onChange={(e) => setDeleteConfirmText(e.target.value)}
513+
placeholder={org?.name ?? ''}
514+
disabled={deletingOrg}
515+
/>
516+
</div>
517+
</div>
518+
<DialogFooter>
519+
<Button
520+
variant="outline"
521+
onClick={() => setDeleteDialogOpen(false)}
522+
disabled={deletingOrg}
523+
>
524+
Cancel
525+
</Button>
526+
<Button
527+
variant="destructive"
528+
onClick={handleDeleteOrganization}
529+
disabled={deletingOrg || !org || deleteConfirmText !== org.name}
530+
>
531+
{deletingOrg ? 'Deleting…' : 'Delete organization'}
532+
</Button>
533+
</DialogFooter>
534+
</DialogContent>
535+
</Dialog>
536+
428537
{/* Invite Member Dialog */}
429538
<Dialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen}>
430539
<DialogContent>

apps/studio/src/routes/projects.$projectId.index.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { Button } from '@/components/ui/button';
3333
import { Badge } from '@/components/ui/badge';
3434
import { Separator } from '@/components/ui/separator';
3535
import { Input } from '@/components/ui/input';
36-
import { useProjectDetail, useRetryProvisioning, useUpdateHostname } from '@/hooks/useProjects';
36+
import { useProjectDetail, useRetryProvisioning, useUpdateHostname, useDeleteProject } from '@/hooks/useProjects';
3737
import { useClient } from '@objectstack/client-react';
3838
import { useProductionGuard } from '@/components/production-guard';
3939
import { toast } from '@/hooks/use-toast';
@@ -49,6 +49,7 @@ function ProjectOverviewComponent() {
4949
const [rotating, setRotating] = useState(false);
5050
const { retry, retrying } = useRetryProvisioning();
5151
const { updateHostname, updating: hostnameUpdating } = useUpdateHostname();
52+
const { remove: deleteProject, deleting } = useDeleteProject();
5253
const [hostnameEditing, setHostnameEditing] = useState(false);
5354
const [hostnameInput, setHostnameInput] = useState('');
5455

@@ -133,6 +134,38 @@ function ProjectOverviewComponent() {
133134
}
134135
};
135136

137+
const handleDelete = async () => {
138+
if (!project) return;
139+
const ok = await guard.confirm({
140+
title: `Delete project "${project.display_name}"?`,
141+
description:
142+
'This permanently deletes the project, its credentials, members, package installations, and the underlying physical database. This action cannot be undone.',
143+
confirmLabel: 'Delete project',
144+
confirmVariant: 'destructive',
145+
requireTypedConfirmation: true,
146+
typedConfirmationValue: project.display_name,
147+
});
148+
if (!ok) return;
149+
try {
150+
const result = await deleteProject(project.id, { force: project.is_default });
151+
const warnings = (result as any)?.warnings as string[] | undefined;
152+
toast({
153+
title: 'Project deleted',
154+
description: warnings?.length
155+
? `Completed with warnings: ${warnings[0]}${warnings.length > 1 ? ` (+${warnings.length - 1} more)` : ''}`
156+
: `${project.display_name} and its database have been removed.`,
157+
variant: warnings?.length ? 'destructive' : undefined,
158+
});
159+
navigate({ to: '/projects' });
160+
} catch (err) {
161+
toast({
162+
title: 'Failed to delete project',
163+
description: (err as Error).message,
164+
variant: 'destructive',
165+
});
166+
}
167+
};
168+
136169
return (
137170
<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
138171
<div className="flex-1 overflow-auto p-6">
@@ -424,9 +457,19 @@ function ProjectOverviewComponent() {
424457
<Separator />
425458

426459
<div className="flex justify-end">
427-
<Button variant="destructive" size="sm" className="gap-2" disabled>
428-
<Trash className="h-3.5 w-3.5" />
429-
Archive project
460+
<Button
461+
variant="destructive"
462+
size="sm"
463+
className="gap-2"
464+
disabled={deleting}
465+
onClick={handleDelete}
466+
>
467+
{deleting ? (
468+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
469+
) : (
470+
<Trash className="h-3.5 w-3.5" />
471+
)}
472+
{deleting ? 'Deleting…' : 'Delete project'}
430473
</Button>
431474
</div>
432475
</>

packages/client/src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,20 @@ export class ObjectStackClient {
669669
return this.unwrapResponse<{ project: any }>(res);
670670
},
671671

672+
/**
673+
* Cascade-delete a project: cleans up credential/member/package_installation
674+
* rows, releases the physical database via the provisioning adapter, and
675+
* removes the `sys_project` row. Default projects require `force: true`.
676+
*/
677+
delete: async (id: string, opts?: { force?: boolean }) => {
678+
const qs = opts?.force ? '?force=1' : '';
679+
const res = await this.fetch(
680+
`${this.baseUrl}/api/v1/cloud/projects/${encodeURIComponent(id)}${qs}`,
681+
{ method: 'DELETE' },
682+
);
683+
return this.unwrapResponse<{ deleted: boolean; projectId: string; warnings: string[] }>(res);
684+
},
685+
672686
/**
673687
* Activate this project for the current session. The server writes
674688
* `active_environment_id` on the better-auth session; subsequent requests
@@ -947,6 +961,28 @@ export class ObjectStackClient {
947961
});
948962
return res.json();
949963
},
964+
965+
/**
966+
* Cascade-delete an organization: tears down every project owned by the
967+
* org (including each project's physical database), then drops the
968+
* organization itself (members + invitations are removed by better-auth's
969+
* organization plugin, or the row is deleted directly if the plugin is
970+
* not loaded).
971+
*
972+
* DELETE /api/v1/cloud/organizations/:id
973+
*/
974+
delete: async (organizationId: string) => {
975+
const res = await this.fetch(
976+
`${this.baseUrl}/api/v1/cloud/organizations/${encodeURIComponent(organizationId)}`,
977+
{ method: 'DELETE' },
978+
);
979+
return this.unwrapResponse<{
980+
deleted: boolean;
981+
organizationId: string;
982+
deletedProjects: number;
983+
warnings: string[];
984+
}>(res);
985+
},
950986
};
951987

952988
/**

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
407407

408408
server.delete(`${prefix}/cloud/projects/:id`, async (req: any, res: any) => {
409409
try {
410-
const result = await dispatcher.handleCloud(`/projects/${req.params.id}`, 'DELETE', {}, {}, { request: req });
410+
const result = await dispatcher.handleCloud(`/projects/${req.params.id}`, 'DELETE', {}, req.query, { request: req });
411+
sendResult(result, res);
412+
} catch (err: any) {
413+
errorResponse(err, res);
414+
}
415+
});
416+
417+
server.delete(`${prefix}/cloud/organizations/:id`, async (req: any, res: any) => {
418+
try {
419+
const result = await dispatcher.handleCloud(`/organizations/${req.params.id}`, 'DELETE', {}, req.query, { request: req });
411420
sendResult(result, res);
412421
} catch (err: any) {
413422
errorResponse(err, res);

0 commit comments

Comments
 (0)