Skip to content

Commit 03e6e61

Browse files
os-zhuangCopilot
andcommitted
feat(studio): gate access to admin role only
Studio is the developer console — schema design, runtime inspection, and metadata editing. Non-admins are now shown a friendly access-denied page instead of being dropped into the developer UI. - SessionUser now exposes optional `role` (derived server-side by customSession() from admin_full_access permission-set or active-org owner/admin membership). - RequireAuth renders <StudioAccessDenied /> when user.role !== 'admin', with CTAs to go to Console or sign out & switch account. - AuthedAiChatPanel is also admin-gated for defense in depth. Note: this is client-side gating. HTTP-level admin middleware on the /_studio/* bundle is a follow-up (Option B). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5a0aeee commit 03e6e61

3 files changed

Lines changed: 99 additions & 5 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* StudioAccessDenied — friendly stop page shown when the signed-in user
5+
* is not a Studio admin. Studio is the developer / admin surface of
6+
* ObjectStack; ordinary end users belong in `/_console/`. This page
7+
* tells them why they're being blocked and offers a one-click path to
8+
* either switch accounts (logout → re-login) or jump back to Console.
9+
*/
10+
11+
import { ShieldAlert, LogOut, ArrowRight } from 'lucide-react';
12+
import { Button } from '@/components/ui/button';
13+
import type { SessionUser } from '@/hooks/useSession';
14+
15+
export interface StudioAccessDeniedProps {
16+
user: SessionUser;
17+
onSwitchAccount: () => void;
18+
}
19+
20+
export function StudioAccessDenied({ user, onSwitchAccount }: StudioAccessDeniedProps) {
21+
return (
22+
<div className="flex min-h-screen w-full items-center justify-center bg-background p-6">
23+
<div className="w-full max-w-md space-y-6 text-center">
24+
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
25+
<ShieldAlert className="h-7 w-7 text-destructive" />
26+
</div>
27+
28+
<div className="space-y-2">
29+
<h1 className="text-2xl font-semibold tracking-tight">Studio is for admins only</h1>
30+
<p className="text-sm text-muted-foreground">
31+
Studio is the developer console for ObjectStack — schema design, runtime
32+
inspection, and metadata editing. Your account doesn't have admin access on
33+
this server.
34+
</p>
35+
</div>
36+
37+
<div className="rounded-md border bg-muted/30 px-4 py-3 text-left text-xs">
38+
<div className="text-muted-foreground">Signed in as</div>
39+
<div className="mt-0.5 font-medium">
40+
{user.name ?? user.email ?? user.id}
41+
{user.email && user.name ? (
42+
<span className="ml-2 text-muted-foreground">({user.email})</span>
43+
) : null}
44+
</div>
45+
<div className="mt-2 text-muted-foreground">
46+
Role: <code className="rounded bg-muted px-1 py-0.5">{user.role ?? 'user'}</code>
47+
</div>
48+
</div>
49+
50+
<div className="flex flex-col gap-2">
51+
<Button asChild className="w-full gap-2">
52+
<a href="/_console/">
53+
Go to Console
54+
<ArrowRight className="h-4 w-4" />
55+
</a>
56+
</Button>
57+
<Button variant="outline" onClick={onSwitchAccount} className="w-full gap-2">
58+
<LogOut className="h-4 w-4" />
59+
Sign out & switch account
60+
</Button>
61+
</div>
62+
63+
<p className="text-xs text-muted-foreground">
64+
Need access? Ask a platform admin to grant you the{' '}
65+
<code className="rounded bg-muted px-1 py-0.5">admin_full_access</code>{' '}
66+
permission set, or make you an owner/admin of your organization.
67+
</p>
68+
</div>
69+
</div>
70+
);
71+
}

apps/studio/src/hooks/useSession.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export interface SessionUser {
3333
name?: string;
3434
image?: string | null;
3535
emailVerified?: boolean;
36+
/**
37+
* Derived role from better-auth `customSession` (see
38+
* `packages/plugins/plugin-auth/src/auth-manager.ts`). Set to `'admin'`
39+
* when the user is a platform admin or an admin/owner of the active
40+
* organization. Studio uses this to gate access — only admins are
41+
* permitted past the shell.
42+
*/
43+
role?: string;
3644
}
3745

3846
export interface SessionData {

apps/studio/src/routes/__root.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Toaster } from '@/components/ui/toaster';
99
import { AiChatPanel } from '@/components/AiChatPanel';
1010
import { ProductionGuardProvider } from '@/components/production-guard';
1111
import { TopBar } from '@/components/top-bar';
12+
import { StudioAccessDenied } from '@/components/StudioAccessDenied';
1213
import { PluginRegistryProvider } from '../plugins';
1314
import { builtInPlugins } from '../plugins/built-in';
1415
import { useObjectStackClient } from '../hooks/useObjectStackClient';
@@ -17,12 +18,13 @@ import { gotoAccountLogin } from '@/lib/auth-redirect';
1718

1819
/**
1920
* Single-tenant Studio shell. Login is delegated to apps/account; if the
20-
* session call comes back empty we bounce there. There is no per-project
21-
* routing, no organization switch, no public-route exemption — Studio is
22-
* purely a metadata browser + runtime debugger on top of one backend.
21+
* session call comes back empty we bounce there. Studio is the
22+
* developer/admin surface of ObjectStack and requires `user.role ===
23+
* 'admin'` — non-admins get a friendly stop page instead of being
24+
* silently bounced into a login loop.
2325
*/
2426
function RequireAuth({ children }: { children: React.ReactNode }) {
25-
const { user, loading } = useSession();
27+
const { user, loading, logout } = useSession();
2628

2729
useEffect(() => {
2830
if (loading) return;
@@ -41,6 +43,19 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
4143

4244
if (!user) return null;
4345

46+
// Admin gate — Studio is not for ordinary end users.
47+
if (user.role !== 'admin') {
48+
return (
49+
<StudioAccessDenied
50+
user={user}
51+
onSwitchAccount={async () => {
52+
await logout();
53+
gotoAccountLogin(window.location.pathname + window.location.search);
54+
}}
55+
/>
56+
);
57+
}
58+
4459
return (
4560
<SidebarProvider>
4661
<div className="flex min-h-screen w-full flex-col">
@@ -55,7 +70,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
5570

5671
function AuthedAiChatPanel() {
5772
const { user } = useSession();
58-
if (!user) return null;
73+
if (!user || user.role !== 'admin') return null;
5974
return <AiChatPanel />;
6075
}
6176

0 commit comments

Comments
 (0)