Skip to content

Commit 51a71a1

Browse files
committed
feat: add device authentication route and update public routes
1 parent d3be3d3 commit 51a71a1

5 files changed

Lines changed: 203 additions & 5 deletions

File tree

apps/studio/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Route as PackageIndexRouteImport } from './routes/$package.index'
2121
import { Route as ProjectsProjectIdRouteImport } from './routes/projects.$projectId'
2222
import { Route as OrgsNewRouteImport } from './routes/orgs.new'
2323
import { Route as OrgsOrgIdRouteImport } from './routes/orgs.$orgId'
24+
import { Route as AuthDeviceRouteImport } from './routes/auth.device'
2425
import { Route as ProjectsProjectIdIndexRouteImport } from './routes/projects.$projectId.index'
2526
import { Route as ProjectsProjectIdPackagesRouteImport } from './routes/projects.$projectId.packages'
2627
import { Route as ProjectsProjectIdApiConsoleRouteImport } from './routes/projects.$projectId.api-console'
@@ -91,6 +92,11 @@ const OrgsOrgIdRoute = OrgsOrgIdRouteImport.update({
9192
path: '/orgs/$orgId',
9293
getParentRoute: () => rootRouteImport,
9394
} as any)
95+
const AuthDeviceRoute = AuthDeviceRouteImport.update({
96+
id: '/auth/device',
97+
path: '/auth/device',
98+
getParentRoute: () => rootRouteImport,
99+
} as any)
94100
const ProjectsProjectIdIndexRoute = ProjectsProjectIdIndexRouteImport.update({
95101
id: '/',
96102
path: '/',
@@ -150,6 +156,7 @@ export interface FileRoutesByFullPath {
150156
'/login': typeof LoginRoute
151157
'/packages': typeof PackagesRoute
152158
'/register': typeof RegisterRoute
159+
'/auth/device': typeof AuthDeviceRoute
153160
'/orgs/$orgId': typeof OrgsOrgIdRoute
154161
'/orgs/new': typeof OrgsNewRoute
155162
'/projects/$projectId': typeof ProjectsProjectIdRouteWithChildren
@@ -172,6 +179,7 @@ export interface FileRoutesByTo {
172179
'/login': typeof LoginRoute
173180
'/packages': typeof PackagesRoute
174181
'/register': typeof RegisterRoute
182+
'/auth/device': typeof AuthDeviceRoute
175183
'/orgs/$orgId': typeof OrgsOrgIdRoute
176184
'/orgs/new': typeof OrgsNewRoute
177185
'/$package': typeof PackageIndexRoute
@@ -194,6 +202,7 @@ export interface FileRoutesById {
194202
'/login': typeof LoginRoute
195203
'/packages': typeof PackagesRoute
196204
'/register': typeof RegisterRoute
205+
'/auth/device': typeof AuthDeviceRoute
197206
'/orgs/$orgId': typeof OrgsOrgIdRoute
198207
'/orgs/new': typeof OrgsNewRoute
199208
'/projects/$projectId': typeof ProjectsProjectIdRouteWithChildren
@@ -219,6 +228,7 @@ export interface FileRouteTypes {
219228
| '/login'
220229
| '/packages'
221230
| '/register'
231+
| '/auth/device'
222232
| '/orgs/$orgId'
223233
| '/orgs/new'
224234
| '/projects/$projectId'
@@ -241,6 +251,7 @@ export interface FileRouteTypes {
241251
| '/login'
242252
| '/packages'
243253
| '/register'
254+
| '/auth/device'
244255
| '/orgs/$orgId'
245256
| '/orgs/new'
246257
| '/$package'
@@ -262,6 +273,7 @@ export interface FileRouteTypes {
262273
| '/login'
263274
| '/packages'
264275
| '/register'
276+
| '/auth/device'
265277
| '/orgs/$orgId'
266278
| '/orgs/new'
267279
| '/projects/$projectId'
@@ -286,6 +298,7 @@ export interface RootRouteChildren {
286298
LoginRoute: typeof LoginRoute
287299
PackagesRoute: typeof PackagesRoute
288300
RegisterRoute: typeof RegisterRoute
301+
AuthDeviceRoute: typeof AuthDeviceRoute
289302
OrgsOrgIdRoute: typeof OrgsOrgIdRoute
290303
OrgsNewRoute: typeof OrgsNewRoute
291304
ProjectsProjectIdRoute: typeof ProjectsProjectIdRouteWithChildren
@@ -379,6 +392,13 @@ declare module '@tanstack/react-router' {
379392
preLoaderRoute: typeof OrgsOrgIdRouteImport
380393
parentRoute: typeof rootRouteImport
381394
}
395+
'/auth/device': {
396+
id: '/auth/device'
397+
path: '/auth/device'
398+
fullPath: '/auth/device'
399+
preLoaderRoute: typeof AuthDeviceRouteImport
400+
parentRoute: typeof rootRouteImport
401+
}
382402
'/projects/$projectId/': {
383403
id: '/projects/$projectId/'
384404
path: '/'
@@ -504,6 +524,7 @@ const rootRouteChildren: RootRouteChildren = {
504524
LoginRoute: LoginRoute,
505525
PackagesRoute: PackagesRoute,
506526
RegisterRoute: RegisterRoute,
527+
AuthDeviceRoute: AuthDeviceRoute,
507528
OrgsOrgIdRoute: OrgsOrgIdRoute,
508529
OrgsNewRoute: OrgsNewRoute,
509530
ProjectsProjectIdRoute: ProjectsProjectIdRouteWithChildren,

apps/studio/src/routes/__root.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { SessionProvider, useSession } from '../hooks/useSession';
1616
import { config } from '@/lib/config';
1717

1818
/** Routes that don't require authentication. */
19-
const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password']);
19+
const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password', '/auth/device']);
2020

2121
/**
2222
* Paths that exist only in multi-project mode — login, registration, the
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { createFileRoute, useNavigate } from '@tanstack/react-router';
4+
import { useState } from 'react';
5+
import { useClient } from '@objectstack/client-react';
6+
import { Button } from '@/components/ui/button';
7+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
8+
import { Input } from '@/components/ui/input';
9+
import { Label } from '@/components/ui/label';
10+
import { toast } from '@/hooks/use-toast';
11+
import { useSession } from '@/hooks/useSession';
12+
import { CheckCircle2, Monitor, AlertCircle } from 'lucide-react';
13+
14+
export const Route = createFileRoute('/auth/device')({
15+
validateSearch: (search: Record<string, unknown>) => ({
16+
code: (search.code as string) ?? '',
17+
}),
18+
component: DeviceAuthPage,
19+
});
20+
21+
function DeviceAuthPage() {
22+
const { code } = Route.useSearch();
23+
const { user, refresh } = useSession();
24+
const client = useClient() as any;
25+
const navigate = useNavigate();
26+
27+
const [email, setEmail] = useState('');
28+
const [password, setPassword] = useState('');
29+
const [submitting, setSubmitting] = useState(false);
30+
const [approved, setApproved] = useState(false);
31+
const [error, setError] = useState('');
32+
33+
if (!code) {
34+
return (
35+
<div className="min-h-screen flex items-center justify-center bg-background">
36+
<Card className="w-full max-w-md">
37+
<CardHeader className="text-center">
38+
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-2" />
39+
<CardTitle>Invalid Request</CardTitle>
40+
<CardDescription>No device code provided.</CardDescription>
41+
</CardHeader>
42+
</Card>
43+
</div>
44+
);
45+
}
46+
47+
if (approved) {
48+
return (
49+
<div className="min-h-screen flex items-center justify-center bg-background">
50+
<Card className="w-full max-w-md">
51+
<CardHeader className="text-center">
52+
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-2" />
53+
<CardTitle>Login Approved</CardTitle>
54+
<CardDescription>
55+
You can close this tab. The CLI has been authenticated successfully.
56+
</CardDescription>
57+
</CardHeader>
58+
</Card>
59+
</div>
60+
);
61+
}
62+
63+
const handleLogin = async (e: React.FormEvent) => {
64+
e.preventDefault();
65+
setError('');
66+
setSubmitting(true);
67+
try {
68+
await client.auth.login({ type: 'email', email, password });
69+
await refresh();
70+
} catch (err: any) {
71+
setError(err?.message ?? 'Login failed');
72+
} finally {
73+
setSubmitting(false);
74+
}
75+
};
76+
77+
const handleApprove = async () => {
78+
setError('');
79+
setSubmitting(true);
80+
try {
81+
// Get the current session token from better-auth cookie/session
82+
const sessionRes = await fetch('/api/v1/auth/get-session', { credentials: 'include' });
83+
const sessionData = await sessionRes.json() as any;
84+
const token = sessionData?.session?.token;
85+
if (!token) throw new Error('No active session');
86+
87+
const res = await fetch('/api/v1/auth/device/approve', {
88+
method: 'POST',
89+
headers: { 'Content-Type': 'application/json' },
90+
credentials: 'include',
91+
body: JSON.stringify({ code, token }),
92+
});
93+
const data = await res.json() as any;
94+
if (!data.success) throw new Error(data.error?.message ?? 'Approval failed');
95+
96+
setApproved(true);
97+
toast({ title: 'CLI login approved', description: 'The CLI has been authenticated.' });
98+
} catch (err: any) {
99+
setError(err?.message ?? 'Approval failed');
100+
} finally {
101+
setSubmitting(false);
102+
}
103+
};
104+
105+
return (
106+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
107+
<Card className="w-full max-w-md">
108+
<CardHeader className="text-center">
109+
<Monitor className="h-10 w-10 text-primary mx-auto mb-2" />
110+
<CardTitle>CLI Login Request</CardTitle>
111+
<CardDescription>
112+
{user
113+
? `Approve CLI access for ${user.email}`
114+
: 'Sign in to approve the CLI login request'}
115+
</CardDescription>
116+
</CardHeader>
117+
<CardContent className="space-y-4">
118+
<div className="bg-muted rounded-md px-4 py-2 text-center">
119+
<p className="text-xs text-muted-foreground mb-1">Device Code</p>
120+
<p className="font-mono font-semibold tracking-widest text-lg">{code}</p>
121+
</div>
122+
123+
{!user ? (
124+
<form onSubmit={handleLogin} className="space-y-4">
125+
<div className="space-y-2">
126+
<Label htmlFor="email">Email</Label>
127+
<Input
128+
id="email"
129+
type="email"
130+
placeholder="you@example.com"
131+
value={email}
132+
onChange={(e) => setEmail(e.target.value)}
133+
required
134+
autoFocus
135+
/>
136+
</div>
137+
<div className="space-y-2">
138+
<Label htmlFor="password">Password</Label>
139+
<Input
140+
id="password"
141+
type="password"
142+
value={password}
143+
onChange={(e) => setPassword(e.target.value)}
144+
required
145+
/>
146+
</div>
147+
{error && <p className="text-sm text-destructive">{error}</p>}
148+
<Button type="submit" className="w-full" disabled={submitting}>
149+
{submitting ? 'Signing in…' : 'Sign In'}
150+
</Button>
151+
</form>
152+
) : (
153+
<div className="space-y-4">
154+
<p className="text-sm text-center text-muted-foreground">
155+
Logged in as <span className="font-medium text-foreground">{user.email}</span>
156+
</p>
157+
{error && <p className="text-sm text-destructive text-center">{error}</p>}
158+
<Button onClick={handleApprove} className="w-full" disabled={submitting}>
159+
{submitting ? 'Approving…' : 'Approve CLI Access'}
160+
</Button>
161+
<Button
162+
variant="outline"
163+
className="w-full"
164+
onClick={() => navigate({ to: '/' })}
165+
>
166+
Cancel
167+
</Button>
168+
</div>
169+
)}
170+
</CardContent>
171+
</Card>
172+
</div>
173+
);
174+
}

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ export class AuthManager {
438438
const code = `${randomBytes(3).toString('hex').toUpperCase()}-${randomBytes(3).toString('hex').toUpperCase()}`;
439439
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes
440440
const cleanBase = (baseUrl || this.config.baseUrl || 'http://localhost:3000').replace(/\/$/, '');
441-
const verificationUrl = `${cleanBase}/auth/device?code=${code}`;
441+
const verificationUrl = `${cleanBase}/_studio/auth/device?code=${code}`;
442442

443443
this.deviceCodes.set(code, { verificationUrl, expiresAt, status: 'pending' });
444444

packages/plugins/plugin-auth/src/auth-plugin.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ describe('AuthPlugin', () => {
138138
const mockRawApp = {
139139
all: vi.fn(),
140140
get: vi.fn(),
141+
post: vi.fn(),
141142
};
142143

143144
const mockHttpServer = {
@@ -175,6 +176,7 @@ describe('AuthPlugin', () => {
175176
const mockRawApp = {
176177
all: vi.fn(),
177178
get: vi.fn(),
179+
post: vi.fn(),
178180
};
179181

180182
const mockHttpServer = {
@@ -272,7 +274,7 @@ describe('AuthPlugin', () => {
272274
});
273275

274276
it('should auto-detect baseUrl from http-server port when port differs', async () => {
275-
const mockRawApp = { all: vi.fn(), get: vi.fn() };
277+
const mockRawApp = { all: vi.fn(), get: vi.fn(), post: vi.fn() };
276278
const mockHttpServer = {
277279
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
278280
patch: vi.fn(), use: vi.fn(),
@@ -308,7 +310,7 @@ describe('AuthPlugin', () => {
308310
(mockContext.registerService as any).mockClear();
309311
await localPlugin.init(mockContext);
310312

311-
const mockRawApp = { all: vi.fn(), get: vi.fn() };
313+
const mockRawApp = { all: vi.fn(), get: vi.fn(), post: vi.fn() };
312314
const mockHttpServer = {
313315
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
314316
patch: vi.fn(), use: vi.fn(),
@@ -340,7 +342,7 @@ describe('AuthPlugin', () => {
340342
(mockContext.registerService as any).mockClear();
341343
await localPlugin.init(mockContext);
342344

343-
const mockRawApp = { all: vi.fn(), get: vi.fn() };
345+
const mockRawApp = { all: vi.fn(), get: vi.fn(), post: vi.fn() };
344346
const mockHttpServer = {
345347
post: vi.fn(), get: vi.fn(), put: vi.fn(), delete: vi.fn(),
346348
patch: vi.fn(), use: vi.fn(),
@@ -403,6 +405,7 @@ describe('AuthPlugin', () => {
403405
const mockRawApp = {
404406
all: vi.fn(),
405407
get: vi.fn(),
408+
post: vi.fn(),
406409
};
407410

408411
const mockHttpServer = {

0 commit comments

Comments
 (0)