Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/setup-deep-link-2794.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
---
'@object-ui/console': patch
'@object-ui/app-shell': patch
---

`/setup` is a real address again — the console gets a stable deep link into platform administration instead of bouncing you back to home

Opening `/_console/setup` landed on `/_console/home`. System settings had no direct URL at all: the only way in was clicking the 「系统设置」 card on the home launcher, which meant the entry point could not be bookmarked, could not be pasted into a support runbook, and was asymmetric with Studio, whose front door has been stable for a while.

The route was never missing — it was occupied. `/setup` mounts the first-run owner-bootstrap wizard (ported here when the Account SPA was retired), and that page evicts everyone it is not meant for: a signed-in visitor via `window.location.assign('/')`, which the landing resolver then turns into `/home` on any multi-app deployment. So the bounce was the wizard doing its job at a URL that had quietly acquired a second, more common meaning.

`/setup` now decides between the two, on the condition the wizard itself already probes — whether the deployment has an owner (`GET /api/v1/auth/bootstrap-status`). No owner yet: the wizard, unchanged. Otherwise: the platform-administration deep link. A live session short-circuits the probe entirely, because `hasOwner: false` cannot be true while somebody is signed in — which also keeps a failed probe from re-creating the bounce it is meant to remove. The verdict is latched for the lifetime of the mount, because `signUp()` flips the session to authenticated while the wizard is still renaming the bootstrap organization, and re-deciding on that flip would unmount the wizard mid-submission.

The destination is read from metadata rather than spelled out. `SetupRedirect` (new, exported from `@object-ui/app-shell` alongside `SystemRedirect`, with its policy available as the pure `resolveSetupAppPath`) resolves the Setup app through the same `appRouteSegment()` helper the home launcher's app cards use, and forwards to the app ROOT — so the page you land on is whatever `AppContent` already resolves as that app's landing item, not a second copy of that policy that would drift the next time Setup's navigation is re-ordered. Search and hash carry across the hop, as they do for `SystemRedirect`.

Two edges are handled rather than papered over. An unauthenticated deep link now goes to `/login?redirect=%2Fsetup` through the console's existing auth-redirect contract — router-derived, so it stays correct under a `<base href>` mount — and lands back on `/setup` after signing in; previously it reached a bare `/login` and the deep link was dropped. And a viewer whose metadata contains no Setup app (the common cause is not a broken build but a missing `setup.access` permission, which filters the app out server-side) gets the shell's ordinary "App not available" screen, with its retry and its one-shot metadata re-check — never a silent landing on home, and never the bare `/apps/setup` pseudo-route, which would have resolved to whichever app happens to be the default.

`/_console/studio` was checked for the same asymmetry and needed no change: bare `/studio` is a declared front door rendering the builder landing, and `/studio/:packageId` already redirects to its Data pillar.
44 changes: 8 additions & 36 deletions apps/console/src/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,21 +12,18 @@
* with extra `<Route>` children.
*/

import { type ReactNode, useEffect } from 'react';
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, Link } from 'react-router-dom';
import { AuthProvider, AuthGuard, useAuth } from '@object-ui/auth';
import { AuthProvider, useAuth } from '@object-ui/auth';
import { DevMasterDetail } from './dev/DevMasterDetail';
import { DevLists } from './dev/DevLists';
import { DevModal } from './dev/DevModal';
import { DevLookup } from './dev/DevLookup';
import { DevRowActions } from './dev/DevRowActions';
import {
ConsoleShell,
ConnectedShell,
RequireOrganization,
RequireAiSurface,
SystemRedirect,
LoadingFallback,
ConsoleToaster,
DefaultHomeLayout,
DefaultHomePage,
Expand All@@ -46,6 +43,8 @@ import {

import { AppContent } from './AppContent';
import { RootLandingRedirect } from './components/RootLandingRedirect';
import { ProtectedRoute } from './components/ProtectedRoute';
import { SetupRoute } from './components/SetupRoute';
import { FormPage } from './components/FormPage';
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
import SharedRecordPage from './pages/SharedRecordPage';
Expand All@@ -60,7 +59,6 @@ import { ResetPasswordPage } from './pages/auth/ResetPasswordPage';
import { SetPasswordPage } from './pages/auth/SetPasswordPage';
import { VerifyEmailPage } from './pages/auth/VerifyEmailPage';
import { VerifyEmailPromptPage } from './pages/auth/VerifyEmailPromptPage';
import { SetupPage } from './pages/auth/SetupPage';
import { OAuthConsentPage } from './pages/auth/OAuthConsentPage';
import { DeviceAuthPage } from './pages/auth/DeviceAuthPage';

Expand DownExpand Up@@ -96,35 +94,6 @@ function resolveBasename(): string {

const BASENAME = resolveBasename();

/**
* ProtectedRoute — replaces app-shell's AuthenticatedRoute. Same composition
* (AuthGuard + ConnectedShell + optional RequireOrganization) but redirects
* unauthenticated visitors to the Console-hosted /login (preserving the
* original Console path as `?redirect=…`).
*/
function LoginRedirect() {
const location = useLocation();
const redirect = location.pathname + location.search;
const search = redirect && redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : '';
return <Navigate to={`/login${search}`} replace />;
}

function ProtectedRoute({
children,
requireOrganization = true,
}: {
children: ReactNode;
requireOrganization?: boolean;
}) {
return (
<AuthGuard fallback={<LoginRedirect />} loadingFallback={<LoadingFallback />}>
<ConnectedShell>
{requireOrganization ? <RequireOrganization>{children}</RequireOrganization> : children}
</ConnectedShell>
</AuthGuard>
);
}

/** Wraps `DefaultHomeLayout` so the FAB gets the signed-in user id. */
function HomeRoute() {
const { user } = useAuth();
Expand DownExpand Up@@ -180,7 +149,10 @@ export function App() {
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/verify-email-prompt" element={<VerifyEmailPromptPage />} />
<Route path="/setup" element={<SetupPage />} />
{/* Public ONLY while the deployment is un-bootstrapped — see
* SetupRoute. Once an owner exists this is the platform-settings
* deep link and carries this host's normal auth guard. */}
<Route path="/setup" element={<SetupRoute />} />
<Route path="/oauth/consent" element={<OAuthConsentPage />} />
<Route path="/auth/device" element={<DeviceAuthPage />} />
{/*
Expand Down
53 changes: 53 additions & 0 deletions apps/console/src/components/ProtectedRoute.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The Console's authenticated-route wrapper and its auth-redirect contract.
*
* Lifted out of `App.tsx` verbatim (objectui#2794) so routes declared in their
* own modules — and tests that need to exercise the REAL redirect rather than a
* transcription of it — can reach the same one. Both were module-private in
* `App.tsx`, so the move adds a seam without widening any published surface,
* and nothing about the behaviour changed.
*/

import type { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { AuthGuard } from '@object-ui/auth';
import { ConnectedShell, RequireOrganization, LoadingFallback } from '@object-ui/app-shell';

/**
* Where an unauthenticated visitor to a protected route goes.
*
* The `?redirect=` target is read off the ROUTER's location, never
* `window.location` — the Console is mounted under a `<base href>` basename in
* every embedded deployment, so a window-derived path would carry the `/_console`
* mount into a value that `LoginPage` then re-prefixes with it (objectui#4168).
*/
export function LoginRedirect() {
const location = useLocation();
const redirect = location.pathname + location.search;
const search = redirect && redirect !== '/' ? `?redirect=${encodeURIComponent(redirect)}` : '';
return <Navigate to={`/login${search}`} replace />;
}

/**
* ProtectedRoute — replaces app-shell's AuthenticatedRoute. Same composition
* (AuthGuard + ConnectedShell + optional RequireOrganization) but redirects
* unauthenticated visitors to the Console-hosted /login (preserving the
* original Console path as `?redirect=…`).
*/
export function ProtectedRoute({
children,
requireOrganization = true,
}: {
children: ReactNode;
requireOrganization?: boolean;
}) {
return (
<AuthGuard fallback={<LoginRedirect />} loadingFallback={<LoadingFallback />}>
<ConnectedShell>
{requireOrganization ? <RequireOrganization>{children}</RequireOrganization> : children}
</ConnectedShell>
</AuthGuard>
);
}
Loading
Loading