Skip to content

Commit f1a7b3d

Browse files
committed
2 parents ee9312f + e4c77d9 commit f1a7b3d

15 files changed

Lines changed: 669 additions & 146 deletions

File tree

apps/server/objectstack.config.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { MetadataPlugin } from '@objectstack/metadata';
2727
import { AIServicePlugin } from '@objectstack/service-ai';
2828
import { AutomationServicePlugin } from '@objectstack/service-automation';
2929
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
30+
import type { SocialProviderConfig, OidcProvidersConfig } from '@objectstack/spec/system';
3031
import { fileURLToPath } from 'node:url';
3132
import { dirname, resolve } from 'node:path';
3233

@@ -38,6 +39,64 @@ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL
3839
? `https://${process.env.VERCEL_URL}` : undefined)
3940
?? 'http://localhost:3000';
4041

42+
function buildSocialProviders(): SocialProviderConfig | undefined {
43+
const providers: SocialProviderConfig = {};
44+
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
45+
providers.google = {
46+
clientId: process.env.GOOGLE_CLIENT_ID,
47+
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
48+
...(process.env.GOOGLE_OAUTH_SCOPES
49+
? { scope: process.env.GOOGLE_OAUTH_SCOPES.split(',').map((s) => s.trim()) }
50+
: {}),
51+
};
52+
}
53+
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
54+
providers.github = {
55+
clientId: process.env.GITHUB_CLIENT_ID,
56+
clientSecret: process.env.GITHUB_CLIENT_SECRET,
57+
};
58+
}
59+
if (process.env.MICROSOFT_CLIENT_ID && process.env.MICROSOFT_CLIENT_SECRET) {
60+
providers.microsoft = {
61+
clientId: process.env.MICROSOFT_CLIENT_ID,
62+
clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
63+
...(process.env.MICROSOFT_TENANT_ID
64+
? { tenantId: process.env.MICROSOFT_TENANT_ID }
65+
: {}),
66+
};
67+
}
68+
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET) {
69+
providers.apple = {
70+
clientId: process.env.APPLE_CLIENT_ID,
71+
clientSecret: process.env.APPLE_CLIENT_SECRET,
72+
};
73+
}
74+
const keys = Object.keys(providers);
75+
if (keys.length > 0) {
76+
console.info(`[auth] enabled social providers: ${keys.join(', ')}`);
77+
return providers;
78+
}
79+
return undefined;
80+
}
81+
82+
function buildOidcProviders(): OidcProvidersConfig | undefined {
83+
const raw = process.env.OIDC_PROVIDERS;
84+
if (!raw) return undefined;
85+
try {
86+
const parsed = JSON.parse(raw) as OidcProvidersConfig;
87+
if (Array.isArray(parsed) && parsed.length > 0) {
88+
console.info(`[auth] enabled OIDC providers: ${parsed.map(p => p.providerId).join(', ')}`);
89+
return parsed;
90+
}
91+
} catch {
92+
console.warn('[auth] Failed to parse OIDC_PROVIDERS env var — expected a JSON array');
93+
}
94+
return undefined;
95+
}
96+
97+
const socialProviders = buildSocialProviders();
98+
const oidcProviders = buildOidcProviders();
99+
41100
// Turso driver for sys namespace — remote when env vars are configured, local SQLite otherwise
42101
const __dirname = dirname(fileURLToPath(import.meta.url));
43102
const tursoDriver = new TursoDriver(
@@ -93,6 +152,8 @@ export default defineStack({
93152
secret: process.env.AUTH_SECRET ?? 'dev-secret-please-change-in-production-min-32-chars',
94153
baseUrl,
95154
plugins: { organization: true },
155+
...(socialProviders ? { socialProviders } : {}),
156+
...(oidcProviders ? { oidcProviders } : {}),
96157
}),
97158
new SecurityPlugin(),
98159
new AuditPlugin(),

apps/server/server/bootstrap.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,12 @@ async function bootstrapSingle(): Promise<BootstrapResult> {
9191
// apps/plugins it references. A schema drift in one of the examples
9292
// shouldn't crash multi-project boots (or the E2E test harness) when
9393
// they don't need those bundles at all.
94-
const dyn = (spec: string) =>
95-
(new Function('s', 'return import(s)') as (s: string) => Promise<any>)(spec);
96-
const stackConfig = (await dyn('../objectstack.config.ts')).default;
94+
//
95+
// Use a proper dynamic import (not Function constructor) so esbuild can
96+
// bundle the config into the Vercel handler. The Function constructor
97+
// bypasses static analysis and prevents bundling, causing runtime errors
98+
// when the source .ts file isn't deployed.
99+
const stackConfig = (await import('../objectstack.config.js')).default;
97100

98101
if (!stackConfig.plugins || stackConfig.plugins.length === 0) {
99102
throw new Error('[Bootstrap] No plugins found in stackConfig');
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { useEffect, useState } from 'react';
4+
import { useClient } from '@objectstack/client-react';
5+
import { Button } from '@/components/ui/button';
6+
7+
interface SocialProvider {
8+
id: string;
9+
name: string;
10+
enabled: boolean;
11+
type?: 'social' | 'oidc';
12+
}
13+
14+
interface Props {
15+
mode: 'sign-in' | 'sign-up';
16+
}
17+
18+
export function SocialSignInButtons({ mode }: Props) {
19+
const client = useClient() as any;
20+
const [providers, setProviders] = useState<SocialProvider[]>([]);
21+
const [loading, setLoading] = useState(true);
22+
23+
useEffect(() => {
24+
if (!client?.auth?.getConfig) return;
25+
client.auth.getConfig()
26+
.then((res: any) => {
27+
const list: SocialProvider[] = res?.socialProviders ?? res?.data?.socialProviders ?? [];
28+
setProviders(list.filter((p) => p.enabled));
29+
})
30+
.catch((err: unknown) => {
31+
console.warn('[SocialSignInButtons] failed to load auth config', err);
32+
})
33+
.finally(() => setLoading(false));
34+
}, [client]);
35+
36+
if (loading || providers.length === 0) return null;
37+
38+
const label = mode === 'sign-in' ? 'Continue with' : 'Sign up with';
39+
40+
return (
41+
<div className="flex flex-col gap-2">
42+
{providers.map((p) => (
43+
<Button
44+
key={p.id}
45+
type="button"
46+
variant="outline"
47+
className="w-full"
48+
onClick={() =>
49+
client.auth.signInWithProvider(p.id, {
50+
callbackURL: window.location.origin + import.meta.env.BASE_URL + 'login',
51+
errorCallbackURL: window.location.origin + import.meta.env.BASE_URL + 'login',
52+
type: p.type ?? 'social',
53+
})
54+
}
55+
>
56+
{/* TODO: replace with provider icon from lucide-react or simple-icons */}
57+
<span className="mr-2 flex h-4 w-4 items-center justify-center rounded-sm bg-muted text-[10px] font-bold uppercase">
58+
{p.id[0]}
59+
</span>
60+
{label} {p.name}
61+
</Button>
62+
))}
63+
<div className="relative my-1">
64+
<div className="absolute inset-0 flex items-center">
65+
<span className="w-full border-t" />
66+
</div>
67+
<div className="relative flex justify-center text-xs uppercase">
68+
<span className="bg-card px-2 text-muted-foreground">or continue with email</span>
69+
</div>
70+
</div>
71+
</div>
72+
);
73+
}

apps/studio/src/routes/__root.tsx

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

1717
/** Routes that don't require authentication. */
18-
const PUBLIC_ROUTES = new Set(['/login', '/register']);
18+
const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password']);
1919

2020
/**
2121
* Routes where an environment selection is NOT required.

apps/studio/src/routes/login.tsx

Lines changed: 71 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
44
import { useEffect, useState } from 'react';
55
import { useClient } from '@objectstack/client-react';
66
import { Button } from '@/components/ui/button';
7-
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
7+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
88
import { Input } from '@/components/ui/input';
99
import { Label } from '@/components/ui/label';
1010
import { toast } from '@/hooks/use-toast';
1111
import { useSession } from '@/hooks/useSession';
1212
import { useProjects } from '@/hooks/useProjects';
13+
import { SocialSignInButtons } from '@/components/auth/social-sign-in-buttons';
14+
import { GalleryVerticalEnd } from 'lucide-react';
1315

1416
export const Route = createFileRoute('/login')({
1517
component: LoginPage,
@@ -68,50 +70,74 @@ function LoginPage() {
6870
};
6971

7072
return (
71-
<div className="flex flex-1 items-center justify-center bg-background px-4">
72-
<Card className="w-full max-w-sm">
73-
<CardHeader>
74-
<CardTitle>Sign in</CardTitle>
75-
<CardDescription>Access your ObjectStack Studio workspace.</CardDescription>
76-
</CardHeader>
77-
<form onSubmit={handleSubmit}>
78-
<CardContent className="space-y-4">
79-
<div className="space-y-1.5">
80-
<Label htmlFor="email">Email</Label>
81-
<Input
82-
id="email"
83-
type="email"
84-
autoComplete="email"
85-
required
86-
value={email}
87-
onChange={(e) => setEmail(e.target.value)}
88-
/>
89-
</div>
90-
<div className="space-y-1.5">
91-
<Label htmlFor="password">Password</Label>
92-
<Input
93-
id="password"
94-
type="password"
95-
autoComplete="current-password"
96-
required
97-
value={password}
98-
onChange={(e) => setPassword(e.target.value)}
99-
/>
100-
</div>
101-
</CardContent>
102-
<CardFooter className="flex flex-col gap-3">
103-
<Button type="submit" className="w-full" disabled={submitting}>
104-
{submitting ? 'Signing in…' : 'Sign in'}
105-
</Button>
106-
<p className="text-xs text-muted-foreground">
107-
No account?{' '}
108-
<Link to="/register" className="text-primary hover:underline">
109-
Create one
110-
</Link>
111-
</p>
112-
</CardFooter>
113-
</form>
114-
</Card>
73+
<div className="flex min-h-svh w-full flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
74+
<div className="flex w-full max-w-sm flex-col gap-6">
75+
<a href="#" className="flex items-center gap-2 self-center font-medium">
76+
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
77+
<GalleryVerticalEnd className="size-4" />
78+
</div>
79+
ObjectStack
80+
</a>
81+
<div className="flex flex-col gap-6">
82+
<Card>
83+
<CardHeader className="text-center">
84+
<CardTitle className="text-xl">Welcome back</CardTitle>
85+
<CardDescription>Access your ObjectStack Studio workspace.</CardDescription>
86+
</CardHeader>
87+
<CardContent>
88+
<form onSubmit={handleSubmit}>
89+
<div className="flex flex-col gap-4">
90+
<SocialSignInButtons mode="sign-in" />
91+
<div className="flex flex-col gap-2">
92+
<Label htmlFor="email">Email</Label>
93+
<Input
94+
id="email"
95+
type="email"
96+
placeholder="m@example.com"
97+
autoComplete="email"
98+
required
99+
value={email}
100+
onChange={(e) => setEmail(e.target.value)}
101+
/>
102+
</div>
103+
<div className="flex flex-col gap-2">
104+
<div className="flex items-center">
105+
<Label htmlFor="password">Password</Label>
106+
<Link
107+
to="/forgot-password"
108+
className="ml-auto text-sm underline-offset-4 hover:underline"
109+
>
110+
Forgot your password?
111+
</Link>
112+
</div>
113+
<Input
114+
id="password"
115+
type="password"
116+
autoComplete="current-password"
117+
required
118+
value={password}
119+
onChange={(e) => setPassword(e.target.value)}
120+
/>
121+
</div>
122+
<Button type="submit" className="w-full" disabled={submitting}>
123+
{submitting ? 'Signing in…' : 'Login'}
124+
</Button>
125+
<p className="text-center text-sm text-muted-foreground">
126+
Don&apos;t have an account?{' '}
127+
<Link to="/register" className="underline underline-offset-4 hover:text-primary">
128+
Sign up
129+
</Link>
130+
</p>
131+
</div>
132+
</form>
133+
</CardContent>
134+
</Card>
135+
<p className="px-6 text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary">
136+
By clicking continue, you agree to our{' '}
137+
<a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>.
138+
</p>
139+
</div>
140+
</div>
115141
</div>
116142
);
117143
}

0 commit comments

Comments
 (0)