Skip to content

Commit 4cc6217

Browse files
committed
2 parents 98e7293 + 19afa86 commit 4cc6217

3 files changed

Lines changed: 168 additions & 27 deletions

File tree

apps/server/server/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ObjectKernel, createRestApiPlugin, createDispatcherPlugin, KernelManage
1717
import type { EnvironmentDriverRegistry } from '@objectstack/runtime';
1818
import type { Hono } from 'hono';
1919
import stackConfig from '../objectstack.config.js';
20+
import { listTemplates } from './templates/registry.js';
2021

2122
// ---------------------------------------------------------------------------
2223
// Runtime shape returned by ensureBoot()
@@ -106,6 +107,10 @@ async function ensureBoot(): Promise<BootResult> {
106107
// Hono app factory
107108
// ---------------------------------------------------------------------------
108109

110+
function envFlag(name: string): boolean {
111+
return ['1', 'true', 'yes', 'on'].includes((process.env[name] ?? '').trim().toLowerCase());
112+
}
113+
109114
async function ensureApp(): Promise<Hono> {
110115
if (_app) return _app;
111116

@@ -114,6 +119,29 @@ async function ensureApp(): Promise<Hono> {
114119
// kernel's service registry (MultiProjectPlugin registered them during
115120
// bootKernel), so they do NOT need to be passed explicitly here.
116121
_app = createHonoApp({ kernel, prefix: '/api/v1' });
122+
123+
// Vercel entrypoint does NOT load plugin-hono-server, so the
124+
// `http.server` service is never registered. The route plugins in
125+
// `multi-project-plugins.ts` early-return when that service is
126+
// missing, leaving `/studio/runtime-config` and `/cloud/templates`
127+
// unmounted (404 / empty list). Mount them directly on the Hono
128+
// instance here so multi-project deployments behave correctly.
129+
if (envFlag('OBJECTSTACK_MULTI_PROJECT')) {
130+
const templatesPayload = listTemplates().map(({ id, label, description, category }) => ({
131+
id,
132+
label,
133+
description,
134+
category,
135+
}));
136+
_app.get('/api/v1/studio/runtime-config', (c) =>
137+
c.json({ singleProject: false }));
138+
_app.get('/api/v1/cloud/templates', (c) =>
139+
c.json({
140+
success: true,
141+
data: { templates: templatesPayload, total: templatesPayload.length },
142+
}));
143+
}
144+
117145
return _app;
118146
}
119147

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

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ 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 { Label } from '@/components/ui/label';
37+
import {
38+
Dialog,
39+
DialogContent,
40+
DialogDescription,
41+
DialogFooter,
42+
DialogHeader,
43+
DialogTitle,
44+
} from '@/components/ui/dialog';
3645
import { useProjectDetail, useRetryProvisioning, useUpdateHostname, useDeleteProject } from '@/hooks/useProjects';
3746
import { useClient } from '@objectstack/client-react';
3847
import { useProductionGuard } from '@/components/production-guard';
@@ -52,6 +61,8 @@ function ProjectOverviewComponent() {
5261
const { remove: deleteProject, deleting } = useDeleteProject();
5362
const [hostnameEditing, setHostnameEditing] = useState(false);
5463
const [hostnameInput, setHostnameInput] = useState('');
64+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
65+
const [deleteConfirmText, setDeleteConfirmText] = useState('');
5566

5667
const project = detail?.project;
5768
const provisioningError =
@@ -134,18 +145,16 @@ function ProjectOverviewComponent() {
134145
}
135146
};
136147

137-
const handleDelete = async () => {
148+
const handleConfirmDelete = async () => {
138149
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;
150+
if (deleteConfirmText !== project.display_name) {
151+
toast({
152+
title: 'Confirmation does not match',
153+
description: `Type "${project.display_name}" to confirm deletion.`,
154+
variant: 'destructive',
155+
});
156+
return;
157+
}
149158
try {
150159
const result = await deleteProject(project.id, { force: project.is_default });
151160
const warnings = (result as any)?.warnings as string[] | undefined;
@@ -156,6 +165,8 @@ function ProjectOverviewComponent() {
156165
: `${project.display_name} and its database have been removed.`,
157166
variant: warnings?.length ? 'destructive' : undefined,
158167
});
168+
setDeleteDialogOpen(false);
169+
setDeleteConfirmText('');
159170
navigate({ to: '/projects' });
160171
} catch (err) {
161172
toast({
@@ -456,26 +467,128 @@ function ProjectOverviewComponent() {
456467

457468
<Separator />
458469

459-
<div className="flex justify-end">
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+
{/* Danger zone — GitHub/Vercel-style cascade-delete card. */}
471+
<Card className="border-destructive/40 p-5">
472+
<h2 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-destructive">
473+
<AlertTriangle className="h-3.5 w-3.5" />
474+
Danger zone
475+
</h2>
476+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
477+
<div className="text-sm">
478+
<p className="font-medium">Delete this project</p>
479+
<p className="text-muted-foreground">
480+
Once deleted, the project, its credentials, members, package
481+
installations, and the underlying database are gone forever.
482+
</p>
483+
</div>
484+
<Button
485+
variant="destructive"
486+
size="sm"
487+
className="gap-2 self-start sm:self-auto"
488+
disabled={deleting}
489+
onClick={() => setDeleteDialogOpen(true)}
490+
>
470491
<Trash className="h-3.5 w-3.5" />
471-
)}
472-
{deleting ? 'Deleting…' : 'Delete project'}
473-
</Button>
474-
</div>
492+
Delete project
493+
</Button>
494+
</div>
495+
</Card>
475496
</>
476497
)}
477498
</div>
478499
</div>
500+
501+
{/* Delete Project Dialog (GitHub/Vercel-style typed confirmation) */}
502+
<Dialog
503+
open={deleteDialogOpen}
504+
onOpenChange={(open) => {
505+
if (deleting) return;
506+
setDeleteDialogOpen(open);
507+
if (!open) setDeleteConfirmText('');
508+
}}
509+
>
510+
<DialogContent className="sm:max-w-lg">
511+
<DialogHeader>
512+
<DialogTitle className="flex items-center gap-2 text-destructive">
513+
<AlertTriangle className="h-5 w-5" />
514+
Delete project
515+
</DialogTitle>
516+
<DialogDescription>
517+
This action <strong>cannot be undone</strong>. This will permanently
518+
delete the <strong>{project?.display_name}</strong> project, its
519+
credentials, members, package installations, and the underlying
520+
physical database.
521+
</DialogDescription>
522+
</DialogHeader>
523+
524+
{project && (
525+
<div className="my-2 space-y-1.5 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs">
526+
<div className="flex flex-col gap-0.5">
527+
<span className="text-muted-foreground">Project</span>
528+
<span className="font-medium">{project.display_name}</span>
529+
</div>
530+
<div className="flex flex-col gap-0.5">
531+
<span className="text-muted-foreground">ID</span>
532+
<code className="break-all font-mono">{project.id}</code>
533+
</div>
534+
{project.database_url && (
535+
<div className="flex flex-col gap-0.5">
536+
<span className="text-muted-foreground">Database</span>
537+
<code className="break-all font-mono">{project.database_url}</code>
538+
</div>
539+
)}
540+
</div>
541+
)}
542+
543+
<div className="grid gap-1.5">
544+
<Label htmlFor="delete-project-confirm">
545+
Please type{' '}
546+
<code className="font-mono text-xs">{project?.display_name}</code>{' '}
547+
to confirm.
548+
</Label>
549+
<Input
550+
id="delete-project-confirm"
551+
value={deleteConfirmText}
552+
onChange={(e) => setDeleteConfirmText(e.target.value)}
553+
placeholder={project?.display_name ?? ''}
554+
autoComplete="off"
555+
autoFocus
556+
disabled={deleting}
557+
/>
558+
</div>
559+
560+
<DialogFooter>
561+
<Button
562+
variant="ghost"
563+
onClick={() => {
564+
setDeleteDialogOpen(false);
565+
setDeleteConfirmText('');
566+
}}
567+
disabled={deleting}
568+
>
569+
Cancel
570+
</Button>
571+
<Button
572+
variant="destructive"
573+
onClick={handleConfirmDelete}
574+
disabled={
575+
deleting ||
576+
!project ||
577+
deleteConfirmText !== project.display_name
578+
}
579+
>
580+
{deleting ? (
581+
<>
582+
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
583+
Deleting…
584+
</>
585+
) : (
586+
'I understand, delete this project'
587+
)}
588+
</Button>
589+
</DialogFooter>
590+
</DialogContent>
591+
</Dialog>
479592
</main>
480593
);
481594
}

content/docs/getting-started/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ os auth login --no-browser
370370
```
371371

372372
If a valid token already exists, `os auth login` exits successfully with
373-
`Already logged in as <email>`. Use `os auth logout` to switch users, or pass
373+
"Already logged in as `<email>`". Use `os auth logout` to switch users, or pass
374374
`--force` to re-authenticate.
375375

376376
For CI and other non-interactive contexts, pass email/password directly:

0 commit comments

Comments
 (0)