Skip to content

Commit f1bc87f

Browse files
committed
feat(auth): implement cross-SPA redirect helpers for login and registration
1 parent c374af8 commit f1bc87f

7 files changed

Lines changed: 125 additions & 279 deletions

File tree

apps/account/src/routes/login.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ function isSafeRedirect(target: string | undefined): target is string {
2424
return !!target && target.startsWith('/') && !target.startsWith('//');
2525
}
2626

27+
/**
28+
* Resolve a redirect target to an absolute path on the current origin.
29+
*
30+
* - Paths beginning with `/_` (e.g. `/_studio/...`, `/_account/...`) are
31+
* already absolute SPA mounts — return them verbatim.
32+
* - Otherwise the target is an Account-internal SPA path (`/account`,
33+
* `/orgs/...`) and gets prefixed with the Account SPA base URL.
34+
*/
35+
function resolveRedirect(target: string): string {
36+
if (target.startsWith('/_')) return target;
37+
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
38+
return base + target;
39+
}
40+
2741
function LoginPage() {
2842
const navigate = useNavigate();
2943
const { redirect } = Route.useSearch();
@@ -36,8 +50,7 @@ function LoginPage() {
3650
useEffect(() => {
3751
if (!user) return;
3852
if (isSafeRedirect(redirect)) {
39-
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
40-
window.location.assign(base + redirect);
53+
window.location.assign(resolveRedirect(redirect));
4154
return;
4255
}
4356
if (!session?.activeOrganizationId) {
@@ -121,7 +134,11 @@ function LoginPage() {
121134
</Button>
122135
<p className="text-center text-sm text-muted-foreground">
123136
Don&apos;t have an account?{' '}
124-
<Link to="/register" className="underline underline-offset-4 hover:text-primary">
137+
<Link
138+
to="/register"
139+
search={redirect ? { redirect } : undefined}
140+
className="underline underline-offset-4 hover:text-primary"
141+
>
125142
Sign up
126143
</Link>
127144
</p>

apps/account/src/routes/register.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,26 @@ import { SocialSignInButtons } from '@/components/auth/social-sign-in-buttons';
1313
import { GalleryVerticalEnd } from 'lucide-react';
1414

1515
export const Route = createFileRoute('/register')({
16+
validateSearch: (search: Record<string, unknown>): { redirect?: string } => {
17+
const r = search.redirect;
18+
return typeof r === 'string' ? { redirect: r } : {};
19+
},
1620
component: RegisterPage,
1721
});
1822

23+
function isSafeRedirect(target: string | undefined): target is string {
24+
return !!target && target.startsWith('/') && !target.startsWith('//');
25+
}
26+
27+
function resolveRedirect(target: string): string {
28+
if (target.startsWith('/_')) return target;
29+
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
30+
return base + target;
31+
}
32+
1933
function RegisterPage() {
2034
const navigate = useNavigate();
35+
const { redirect } = Route.useSearch();
2136
const client = useClient() as any;
2237
const { user, refresh } = useSession();
2338
const [name, setName] = useState('');
@@ -26,10 +41,13 @@ function RegisterPage() {
2641
const [submitting, setSubmitting] = useState(false);
2742

2843
useEffect(() => {
29-
if (user) {
30-
navigate({ to: '/' });
44+
if (!user) return;
45+
if (isSafeRedirect(redirect)) {
46+
window.location.assign(resolveRedirect(redirect));
47+
return;
3148
}
32-
}, [user, navigate]);
49+
navigate({ to: '/' });
50+
}, [user, navigate, redirect]);
3351

3452
const handleSubmit = async (e: React.FormEvent) => {
3553
e.preventDefault();
@@ -109,7 +127,11 @@ function RegisterPage() {
109127
</Button>
110128
<p className="text-center text-sm text-muted-foreground">
111129
Already have an account?{' '}
112-
<Link to="/login" className="underline underline-offset-4 hover:text-primary">
130+
<Link
131+
to="/login"
132+
search={redirect ? { redirect } : undefined}
133+
className="underline underline-offset-4 hover:text-primary"
134+
>
113135
Sign in
114136
</Link>
115137
</p>

apps/studio/src/components/user-menu.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
1919
import { Button } from '@/components/ui/button';
2020
import { useSession } from '@/hooks/useSession';
2121
import { config } from '@/lib/config';
22+
import { gotoAccount, gotoAccountLogin } from '@/lib/auth-redirect';
2223

2324
function initials(name?: string, email?: string): string {
2425
const src = (name || email || '?').trim();
@@ -43,7 +44,7 @@ export function UserMenu() {
4344
variant="outline"
4445
size="sm"
4546
className="h-8"
46-
onClick={() => navigate({ to: '/login' })}
47+
onClick={() => gotoAccountLogin()}
4748
>
4849
Sign in
4950
</Button>
@@ -54,7 +55,7 @@ export function UserMenu() {
5455
try {
5556
await logout();
5657
} finally {
57-
navigate({ to: '/login' });
58+
gotoAccountLogin('/');
5859
}
5960
};
6061

@@ -86,7 +87,7 @@ export function UserMenu() {
8687
<DropdownMenuSeparator />
8788
{!config.singleProject && (
8889
<>
89-
<DropdownMenuItem onSelect={() => navigate({ to: '/orgs' })}>
90+
<DropdownMenuItem onSelect={() => gotoAccount('/orgs')}>
9091
<Building2 className="mr-2 h-3.5 w-3.5" />
9192
Organizations
9293
</DropdownMenuItem>
@@ -96,7 +97,7 @@ export function UserMenu() {
9697
</DropdownMenuItem>
9798
</>
9899
)}
99-
<DropdownMenuItem disabled>
100+
<DropdownMenuItem onSelect={() => gotoAccount('/account')}>
100101
<UserIcon className="mr-2 h-3.5 w-3.5" />
101102
Account settings
102103
</DropdownMenuItem>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Cross-SPA auth redirect helpers.
5+
*
6+
* Studio defers all sign-in / sign-up UI to the Account SPA mounted at
7+
* `/_account/`. These helpers build absolute URLs preserving the original
8+
* Studio location so the user lands back where they started after auth.
9+
*/
10+
11+
const ACCOUNT_BASE = '/_account';
12+
13+
/** Compose a Studio absolute path from `pathname + search`. */
14+
function currentStudioHref(): string {
15+
if (typeof window === 'undefined') return '/';
16+
return window.location.pathname + window.location.search;
17+
}
18+
19+
/**
20+
* Hard-navigate the browser to the Account login page, preserving the
21+
* current Studio path as `?redirect=...`.
22+
*/
23+
export function gotoAccountLogin(redirect?: string): void {
24+
const target = redirect ?? currentStudioHref();
25+
const url = `${ACCOUNT_BASE}/login?redirect=${encodeURIComponent(target)}`;
26+
window.location.assign(url);
27+
}
28+
29+
/** Hard-navigate to a path under the Account SPA (e.g. `/account`, `/orgs`). */
30+
export function gotoAccount(path: string): void {
31+
const clean = path.startsWith('/') ? path : `/${path}`;
32+
window.location.assign(`${ACCOUNT_BASE}${clean}`);
33+
}

apps/studio/src/routes/__root.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { builtInPlugins } from '../plugins/built-in';
1414
import { useObjectStackClient } from '../hooks/useObjectStackClient';
1515
import { SessionProvider, useSession } from '../hooks/useSession';
1616
import { config } from '@/lib/config';
17+
import { gotoAccountLogin } from '@/lib/auth-redirect';
1718

1819
/** Routes that don't require authentication. */
1920
const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password', '/auth/device']);
@@ -88,9 +89,11 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
8889
if (config.skipAuth) return;
8990
if (loading) return;
9091
if (!user && !isPublic) {
91-
navigate({ to: '/login' });
92+
// Use the raw browser path (includes the `/_studio` base) so
93+
// Account can bounce the user back to the exact same Studio URL.
94+
gotoAccountLogin(window.location.pathname + window.location.search);
9295
}
93-
}, [user, loading, isPublic, navigate]);
96+
}, [user, loading, isPublic, location.pathname, location.searchStr]);
9497

9598
if (loading && !user) {
9699
return (

apps/studio/src/routes/login.tsx

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

3-
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
4-
import { useEffect, 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 { useProjects } from '@/hooks/useProjects';
13-
import { SocialSignInButtons } from '@/components/auth/social-sign-in-buttons';
14-
import { GalleryVerticalEnd } from 'lucide-react';
3+
/**
4+
* Studio's `/login` route — kept only for backwards-compatible URLs.
5+
*
6+
* Studio delegates all auth UI to the Account SPA at `/_account/login`.
7+
* Visitors landing here are redirected immediately, preserving any
8+
* `?redirect=...` they brought along.
9+
*/
10+
11+
import { createFileRoute, useSearch } from '@tanstack/react-router';
12+
import { useEffect } from 'react';
13+
import { gotoAccountLogin } from '@/lib/auth-redirect';
1514

1615
export const Route = createFileRoute('/login')({
1716
validateSearch: (search: Record<string, unknown>): { redirect?: string } => {
1817
const r = search.redirect;
1918
return typeof r === 'string' ? { redirect: r } : {};
2019
},
21-
component: LoginPage,
20+
component: LoginRedirect,
2221
});
2322

24-
function isSafeRedirect(target: string | undefined): target is string {
25-
return !!target && target.startsWith('/') && !target.startsWith('//');
26-
}
27-
28-
function LoginPage() {
29-
const navigate = useNavigate();
30-
const { redirect } = Route.useSearch();
31-
const client = useClient() as any;
32-
const { session, user, refresh } = useSession();
33-
const { projects, loading: projectsLoading } = useProjects();
34-
const [email, setEmail] = useState('');
35-
const [password, setPassword] = useState('');
36-
const [submitting, setSubmitting] = useState(false);
37-
23+
function LoginRedirect() {
24+
const { redirect } = useSearch({ from: '/login' });
3825
useEffect(() => {
39-
if (!user) return;
40-
if (isSafeRedirect(redirect)) {
41-
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
42-
window.location.assign(base + redirect);
43-
return;
44-
}
45-
if (!session?.activeOrganizationId) {
46-
navigate({ to: '/orgs' });
47-
return;
48-
}
49-
if (projectsLoading) return;
50-
51-
const lastProjectId = localStorage.getItem('objectstack.lastProjectId');
52-
const targetProject =
53-
(lastProjectId && projects.find((p) => p.id === lastProjectId)) ||
54-
projects.find((p) => p.is_default) ||
55-
projects[0];
56-
57-
if (targetProject) {
58-
navigate({
59-
to: '/projects/$projectId',
60-
params: { projectId: targetProject.id },
61-
});
62-
} else {
63-
navigate({ to: '/projects' });
64-
}
65-
}, [user, session, projects, projectsLoading, navigate, redirect]);
66-
67-
const handleSubmit = async (e: React.FormEvent) => {
68-
e.preventDefault();
69-
if (!client?.auth) return;
70-
setSubmitting(true);
71-
try {
72-
await client.auth.login({ type: 'email', email, password });
73-
await refresh();
74-
toast({ title: 'Welcome back' });
75-
} catch (err) {
76-
toast({
77-
title: 'Sign in failed',
78-
description: (err as Error).message,
79-
variant: 'destructive',
80-
});
81-
} finally {
82-
setSubmitting(false);
83-
}
84-
};
85-
86-
return (
87-
<div className="flex min-h-svh w-full flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
88-
<div className="flex w-full max-w-sm flex-col gap-6">
89-
<a href="#" className="flex items-center gap-2 self-center font-medium">
90-
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
91-
<GalleryVerticalEnd className="size-4" />
92-
</div>
93-
ObjectStack
94-
</a>
95-
<div className="flex flex-col gap-6">
96-
<Card>
97-
<CardHeader className="text-center">
98-
<CardTitle className="text-xl">Welcome back</CardTitle>
99-
<CardDescription>Access your ObjectStack Studio workspace.</CardDescription>
100-
</CardHeader>
101-
<CardContent>
102-
<form onSubmit={handleSubmit}>
103-
<div className="flex flex-col gap-4">
104-
<SocialSignInButtons mode="sign-in" />
105-
<div className="flex flex-col gap-2">
106-
<Label htmlFor="email">Email</Label>
107-
<Input
108-
id="email"
109-
type="email"
110-
placeholder="m@example.com"
111-
autoComplete="email"
112-
required
113-
value={email}
114-
onChange={(e) => setEmail(e.target.value)}
115-
/>
116-
</div>
117-
<div className="flex flex-col gap-2">
118-
<div className="flex items-center">
119-
<Label htmlFor="password">Password</Label>
120-
<Link
121-
to="/forgot-password"
122-
className="ml-auto text-sm underline-offset-4 hover:underline"
123-
>
124-
Forgot your password?
125-
</Link>
126-
</div>
127-
<Input
128-
id="password"
129-
type="password"
130-
autoComplete="current-password"
131-
required
132-
value={password}
133-
onChange={(e) => setPassword(e.target.value)}
134-
/>
135-
</div>
136-
<Button type="submit" className="w-full" disabled={submitting}>
137-
{submitting ? 'Signing in…' : 'Login'}
138-
</Button>
139-
<p className="text-center text-sm text-muted-foreground">
140-
Don&apos;t have an account?{' '}
141-
<Link to="/register" className="underline underline-offset-4 hover:text-primary">
142-
Sign up
143-
</Link>
144-
</p>
145-
</div>
146-
</form>
147-
</CardContent>
148-
</Card>
149-
<p className="px-6 text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary">
150-
By clicking continue, you agree to our{' '}
151-
<a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>.
152-
</p>
153-
</div>
154-
</div>
155-
</div>
156-
);
26+
gotoAccountLogin(redirect);
27+
}, [redirect]);
28+
return null;
15729
}

0 commit comments

Comments
 (0)