Skip to content

Commit aea2aae

Browse files
committed
feat: add setup route for first-run user creation and organization provisioning
1 parent acd8107 commit aea2aae

19 files changed

Lines changed: 465 additions & 170 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- **`OBJECTSTACK_MODE` replaces `OBJECTSTACK_MULTI_PROJECT`** — Boot-mode selection is now driven by a single `OBJECTSTACK_MODE` variable accepting `standalone` (default) or `cloud`. The legacy `OBJECTSTACK_MULTI_PROJECT=true` flag remains as a deprecated alias (with a one-shot console warning at boot) and will be removed in the next major release. Root `pnpm dev` now starts in standalone mode; use `pnpm dev:cloud` for the multi-project / control-plane shape. Updated `apps/server/objectstack.config.ts`, `apps/studio/server/index.ts`, `.env.example`, the cloud-deployment guide, and the north-star env table.
12+
1013
### Added
1114
- **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).
1215
- **`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.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ Tests: [packages/spec/src/system/project-artifact.test.ts](packages/spec/src/sys
171171
明确 ObjectOS 启动输入 = **Artifact**(不可变、可缓存的元数据信封)+ **Deployment Config**(业务 DB 坐标、凭据、项目身份、密钥;不进 artifact)。详见 [north-star.mdx §6.3](content/docs/concepts/north-star.mdx)
172172

173173
- [x] north-star.mdx §6.3 增补 Runtime Inputs 节(含本地单 project env 表 + 反模式说明)
174-
- [x] 实现本地 single / multi-project env 路径:`OBJECTSTACK_MULTI_PROJECT` / `OBJECTSTACK_PROJECT_ID` / `OBJECTSTACK_DATABASE_URL` / `OBJECTSTACK_DATABASE_DRIVER` / `OBJECTSTACK_ARTIFACT_PATH`(默认 `./dist/objectstack.json`)/ `AUTH_SECRET`
174+
- [x] 实现本地 standalone / cloud env 路径:`OBJECTSTACK_MODE` (旧 `OBJECTSTACK_MULTI_PROJECT`) / `OBJECTSTACK_PROJECT_ID` / `OBJECTSTACK_DATABASE_URL` / `OBJECTSTACK_DATABASE_DRIVER` / `OBJECTSTACK_ARTIFACT_PATH`(默认 `./dist/objectstack.json`)/ `AUTH_SECRET`
175175
- [x] 修复 Drift:`ProjectKernelFactory` 不再直连控制面 DB 读 `sys_project` / `sys_project_credential`,改走 Artifact API + Deployment Config 注入(`localProject` 分支)
176176
- [x] [apps/server/objectstack.config.ts](apps/server/objectstack.config.ts) 的 env 命名收敛到 `OBJECTSTACK_*` 前缀,`isLocalMode` 分流本地/云端路径
177177

apps/account/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import { Route as rootRouteImport } from './routes/__root'
1212
import { Route as VerifyEmailRouteImport } from './routes/verify-email'
13+
import { Route as SetupRouteImport } from './routes/setup'
1314
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
1415
import { Route as RegisterRouteImport } from './routes/register'
1516
import { Route as LoginRouteImport } from './routes/login'
@@ -39,6 +40,11 @@ const VerifyEmailRoute = VerifyEmailRouteImport.update({
3940
path: '/verify-email',
4041
getParentRoute: () => rootRouteImport,
4142
} as any)
43+
const SetupRoute = SetupRouteImport.update({
44+
id: '/setup',
45+
path: '/setup',
46+
getParentRoute: () => rootRouteImport,
47+
} as any)
4248
const ResetPasswordRoute = ResetPasswordRouteImport.update({
4349
id: '/reset-password',
4450
path: '/reset-password',
@@ -168,6 +174,7 @@ export interface FileRoutesByFullPath {
168174
'/login': typeof LoginRoute
169175
'/register': typeof RegisterRoute
170176
'/reset-password': typeof ResetPasswordRoute
177+
'/setup': typeof SetupRoute
171178
'/verify-email': typeof VerifyEmailRoute
172179
'/accept-invitation/$invitationId': typeof AcceptInvitationInvitationIdRoute
173180
'/account/profile': typeof AccountProfileRoute
@@ -193,6 +200,7 @@ export interface FileRoutesByTo {
193200
'/login': typeof LoginRoute
194201
'/register': typeof RegisterRoute
195202
'/reset-password': typeof ResetPasswordRoute
203+
'/setup': typeof SetupRoute
196204
'/verify-email': typeof VerifyEmailRoute
197205
'/accept-invitation/$invitationId': typeof AcceptInvitationInvitationIdRoute
198206
'/account/profile': typeof AccountProfileRoute
@@ -219,6 +227,7 @@ export interface FileRoutesById {
219227
'/login': typeof LoginRoute
220228
'/register': typeof RegisterRoute
221229
'/reset-password': typeof ResetPasswordRoute
230+
'/setup': typeof SetupRoute
222231
'/verify-email': typeof VerifyEmailRoute
223232
'/accept-invitation/$invitationId': typeof AcceptInvitationInvitationIdRoute
224233
'/account/profile': typeof AccountProfileRoute
@@ -247,6 +256,7 @@ export interface FileRouteTypes {
247256
| '/login'
248257
| '/register'
249258
| '/reset-password'
259+
| '/setup'
250260
| '/verify-email'
251261
| '/accept-invitation/$invitationId'
252262
| '/account/profile'
@@ -272,6 +282,7 @@ export interface FileRouteTypes {
272282
| '/login'
273283
| '/register'
274284
| '/reset-password'
285+
| '/setup'
275286
| '/verify-email'
276287
| '/accept-invitation/$invitationId'
277288
| '/account/profile'
@@ -297,6 +308,7 @@ export interface FileRouteTypes {
297308
| '/login'
298309
| '/register'
299310
| '/reset-password'
311+
| '/setup'
300312
| '/verify-email'
301313
| '/accept-invitation/$invitationId'
302314
| '/account/profile'
@@ -324,6 +336,7 @@ export interface RootRouteChildren {
324336
LoginRoute: typeof LoginRoute
325337
RegisterRoute: typeof RegisterRoute
326338
ResetPasswordRoute: typeof ResetPasswordRoute
339+
SetupRoute: typeof SetupRoute
327340
VerifyEmailRoute: typeof VerifyEmailRoute
328341
AcceptInvitationInvitationIdRoute: typeof AcceptInvitationInvitationIdRoute
329342
AuthDeviceRoute: typeof AuthDeviceRoute
@@ -342,6 +355,13 @@ declare module '@tanstack/react-router' {
342355
preLoaderRoute: typeof VerifyEmailRouteImport
343356
parentRoute: typeof rootRouteImport
344357
}
358+
'/setup': {
359+
id: '/setup'
360+
path: '/setup'
361+
fullPath: '/setup'
362+
preLoaderRoute: typeof SetupRouteImport
363+
parentRoute: typeof rootRouteImport
364+
}
345365
'/reset-password': {
346366
id: '/reset-password'
347367
path: '/reset-password'
@@ -553,6 +573,7 @@ const rootRouteChildren: RootRouteChildren = {
553573
LoginRoute: LoginRoute,
554574
RegisterRoute: RegisterRoute,
555575
ResetPasswordRoute: ResetPasswordRoute,
576+
SetupRoute: SetupRoute,
556577
VerifyEmailRoute: VerifyEmailRoute,
557578
AcceptInvitationInvitationIdRoute: AcceptInvitationInvitationIdRoute,
558579
AuthDeviceRoute: AuthDeviceRoute,

apps/account/src/routes/__root.tsx

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
4-
import { useEffect, useMemo } from 'react';
4+
import { useEffect, useMemo, useState } from 'react';
55
import { ObjectStackProvider } from '@objectstack/client-react';
66
import { ObjectStackClient } from '@objectstack/client';
77
import { Toaster } from '@/components/ui/toaster';
@@ -19,41 +19,87 @@ const PUBLIC_ROUTES = new Set([
1919
'/reset-password',
2020
'/verify-email',
2121
'/auth/device',
22+
'/setup',
2223
]);
2324

2425
function isPublic(pathname: string): boolean {
2526
return PUBLIC_ROUTES.has(pathname) || pathname.startsWith('/accept-invitation/');
2627
}
2728

29+
/**
30+
* Probes `/api/v1/auth/bootstrap-status` once per app load. Returns:
31+
* - `null` while loading
32+
* - `true` if at least one user exists (normal mode)
33+
* - `false` if the database is empty (first-run setup required)
34+
*
35+
* On any error we assume `true` so the SPA falls through to its normal
36+
* login flow rather than hijacking the user with a setup screen.
37+
*/
38+
function useBootstrapStatus(): boolean | null {
39+
const [hasOwner, setHasOwner] = useState<boolean | null>(null);
40+
useEffect(() => {
41+
let cancelled = false;
42+
(async () => {
43+
try {
44+
const res = await fetch('/api/v1/auth/bootstrap-status');
45+
if (!res.ok) {
46+
if (!cancelled) setHasOwner(true);
47+
return;
48+
}
49+
const data = await res.json() as { hasOwner?: boolean };
50+
if (!cancelled) setHasOwner(data.hasOwner !== false);
51+
} catch {
52+
if (!cancelled) setHasOwner(true);
53+
}
54+
})();
55+
return () => { cancelled = true; };
56+
}, []);
57+
return hasOwner;
58+
}
59+
2860
function RequireAuth({ children }: { children: React.ReactNode }) {
2961
const { user, loading } = useSession();
3062
const navigate = useNavigate();
3163
const location = useLocation();
3264
const pub = isPublic(location.pathname);
33-
// OAuth consent screen renders fullscreen without sidebar/topbar chrome
34-
// — it's a flow page, not part of the account portal proper. Still
35-
// requires authentication.
3665
const fullscreenAuthed = location.pathname.startsWith('/oauth/');
66+
const hasOwner = useBootstrapStatus();
67+
const onSetup = location.pathname === '/setup';
3768

69+
// First-run redirect: if there's no owner yet, force /setup. This runs
70+
// before the auth check below so the user isn't bounced to /login first.
71+
useEffect(() => {
72+
if (hasOwner === false && !onSetup && !user) {
73+
navigate({ to: '/setup', replace: true });
74+
}
75+
}, [hasOwner, onSetup, user, navigate]);
76+
77+
// If the database has been bootstrapped, the /setup page should redirect
78+
// unauthenticated visitors back to /login (handled inside the page itself).
3879
useEffect(() => {
3980
if (loading) return;
81+
if (hasOwner === false) return; // setup flow takes precedence
4082
if (!user && !pub) {
4183
navigate({
4284
to: '/login',
4385
search: { redirect: location.pathname + location.searchStr },
4486
replace: true,
4587
});
4688
}
47-
}, [user, loading, pub, navigate, location.pathname, location.searchStr]);
89+
}, [user, loading, pub, navigate, location.pathname, location.searchStr, hasOwner]);
4890

49-
if (loading && !user) {
91+
if (hasOwner === null || (loading && !user)) {
5092
return (
5193
<div className="flex min-h-screen w-full flex-1 items-center justify-center bg-background">
5294
<div className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
5395
</div>
5496
);
5597
}
5698

99+
if (hasOwner === false && !onSetup) {
100+
return null;
101+
}
102+
57103
if (!user && !pub) {
58104
return null;
59105
}

0 commit comments

Comments
 (0)