Skip to content

Commit a3ab2d4

Browse files
hotlongCopilot
andcommitted
feat(account): auto-redirect project login page to cloud platform SSO
Cloud-managed projects expose the control plane as a platform-SSO identity provider (`socialProviders.objectstack-cloud`). Showing a second local login form alongside the SSO button creates a confusing back-and-forth between two near-identical login screens during the OAuth dance — users report "哪个登录页才是真的". When the project's /api/v1/auth/config advertises the `objectstack-cloud` provider, the project's /_account/login now skips its own form entirely and triggers `signInWithProvider('objectstack-cloud')` on mount, sending the user straight to the cloud IdP. Returning users transit the page in ~200ms as a spinner splash, never seeing a second form. Detection skips the auto-redirect in three cases: 1. `?fallback=1` — escape hatch for ops when the cloud IdP is unreachable, or for legacy local-only accounts created before SSO was enabled. 2. Already-authenticated user — let the existing post-login effect navigate. 3. URL carries `client_id` + `redirect_uri` — we're inside an in-flight oauth2 authorize hand-off on the IdP side; the previous commit's post-login effect handles those. Re-triggering signIn would clobber the original RP's request. The SSO call passes `errorCallbackURL = /login?fallback=1` so a failed cloud bounce drops the user back on the local form instead of looping the auto-redirect. Cloud's own /_account/login is naturally unaffected: its auth config does not list `objectstack-cloud` (cloud is the IdP, not its own RP), so the probe falls through and the form renders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cedfb93 commit a3ab2d4

1 file changed

Lines changed: 107 additions & 3 deletions

File tree

apps/account/src/routes/login.tsx

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,20 @@ import { SocialSignInButtons } from '@/components/auth/social-sign-in-buttons';
1414
import { GalleryVerticalEnd } from 'lucide-react';
1515

1616
export const Route = createFileRoute('/login')({
17-
validateSearch: (search: Record<string, unknown>): { redirect?: string } => {
17+
validateSearch: (
18+
search: Record<string, unknown>,
19+
): { redirect?: string; fallback?: boolean } => {
1820
const r = search.redirect;
19-
return typeof r === 'string' ? { redirect: r } : {};
21+
const f = search.fallback;
22+
return {
23+
...(typeof r === 'string' ? { redirect: r } : {}),
24+
// Escape hatch for the platform-SSO auto-redirect (see useEffect below).
25+
// Any truthy value (`?fallback=1`, `?fallback=true`) flips the page back
26+
// to the legacy local email/password form. Used by:
27+
// - the SSO `errorCallbackURL` so a failed cloud bounce doesn't loop
28+
// - operators needing local sign-in when the cloud IdP is unreachable
29+
...(f === '1' || f === 'true' || f === true ? { fallback: true } : {}),
30+
};
2031
},
2132
component: LoginPage,
2233
});
@@ -42,7 +53,7 @@ function resolveRedirect(target: string): string {
4253
function LoginPage() {
4354
const { t } = useObjectTranslation();
4455
const navigate = useNavigate();
45-
const { redirect } = Route.useSearch();
56+
const { redirect, fallback } = Route.useSearch();
4657
const client = useClient() as any;
4758
const {
4859
session,
@@ -57,6 +68,92 @@ function LoginPage() {
5768
const [password, setPassword] = useState('');
5869
const [submitting, setSubmitting] = useState(false);
5970
const [autoSelectingOrg, setAutoSelectingOrg] = useState(false);
71+
// Platform-SSO auto-redirect state — see effect below. Starts `true` so the
72+
// page renders the redirect splash instead of the local form during the
73+
// initial config probe, eliminating the brief flash-of-form on
74+
// SSO-enabled deployments (Airtable-style UX).
75+
const [ssoAutoRedirecting, setSsoAutoRedirecting] = useState<boolean>(!fallback);
76+
77+
// Platform-SSO auto-redirect.
78+
//
79+
// Cloud-managed projects expose the cloud control plane as a platform-SSO
80+
// identity provider (registered as the social provider id
81+
// `objectstack-cloud`). When present, presenting a *second* local login
82+
// form alongside the SSO button just confuses end users — they bounce
83+
// between two near-identical login screens during the OAuth dance.
84+
//
85+
// This effect detects "we're an RP for cloud platform SSO" by probing
86+
// `/api/v1/auth/config` for the `objectstack-cloud` provider, and if so
87+
// immediately triggers the OAuth flow — no local form is ever rendered.
88+
//
89+
// Skip conditions:
90+
// - `?fallback=1` on the URL (operator escape hatch)
91+
// - we're inside the IdP-side login (cloud's own /_account/login does
92+
// NOT advertise `objectstack-cloud` as a provider — only RP projects do)
93+
// - we're inside an in-flight oauth2 authorize hand-off
94+
// (`?client_id=...&redirect_uri=...`) — that path is handled by the
95+
// post-login useEffect further down, and re-triggering signIn here
96+
// would clobber the original RP's request
97+
useEffect(() => {
98+
if (fallback) {
99+
setSsoAutoRedirecting(false);
100+
return;
101+
}
102+
if (user) {
103+
// Already authenticated — let the post-login effect handle navigation.
104+
setSsoAutoRedirecting(false);
105+
return;
106+
}
107+
if (typeof window !== 'undefined') {
108+
const sp = new URLSearchParams(window.location.search);
109+
if (sp.has('client_id') && sp.has('redirect_uri')) {
110+
setSsoAutoRedirecting(false);
111+
return;
112+
}
113+
}
114+
if (!client?.auth?.getConfig || !client?.auth?.signInWithProvider) {
115+
setSsoAutoRedirecting(false);
116+
return;
117+
}
118+
let cancelled = false;
119+
client.auth.getConfig()
120+
.then((res: any) => {
121+
if (cancelled) return;
122+
const list: Array<{ id: string; enabled: boolean; type?: string }> =
123+
res?.socialProviders ?? res?.data?.socialProviders ?? [];
124+
const cloud = list.find(
125+
(p) => p.enabled && p.id === 'objectstack-cloud',
126+
);
127+
if (!cloud) {
128+
setSsoAutoRedirecting(false);
129+
return;
130+
}
131+
const base = window.location.origin + import.meta.env.BASE_URL;
132+
const fallbackQs = new URLSearchParams({ fallback: '1' });
133+
if (redirect) fallbackQs.set('redirect', redirect);
134+
const errorUrl = `${base}login?${fallbackQs.toString()}`;
135+
const successUrl = isSafeRedirect(redirect)
136+
? window.location.origin + resolveRedirect(redirect)
137+
: window.location.origin + '/';
138+
client.auth
139+
.signInWithProvider(cloud.id, {
140+
callbackURL: successUrl,
141+
errorCallbackURL: errorUrl,
142+
type: (cloud.type as any) ?? 'oidc',
143+
})
144+
.catch((err: unknown) => {
145+
console.warn('[LoginPage] platform SSO auto-redirect failed', err);
146+
if (!cancelled) setSsoAutoRedirecting(false);
147+
});
148+
})
149+
.catch((err: unknown) => {
150+
console.warn('[LoginPage] failed to probe auth config', err);
151+
if (!cancelled) setSsoAutoRedirecting(false);
152+
});
153+
return () => {
154+
cancelled = true;
155+
};
156+
}, [client, fallback, redirect, user]);
60157

61158
useEffect(() => {
62159
if (!user) return;
@@ -147,6 +244,12 @@ function LoginPage() {
147244

148245
return (
149246
<div className="flex min-h-svh w-full flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
247+
{ssoAutoRedirecting ? (
248+
<div className="flex flex-col items-center gap-3 text-sm text-muted-foreground">
249+
<div className="size-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
250+
<span>{t('auth.login.redirecting', { defaultValue: 'Redirecting to ObjectStack…' })}</span>
251+
</div>
252+
) : (
150253
<div className="flex w-full max-w-sm flex-col gap-6">
151254
<a href="#" className="flex items-center gap-2 self-center font-medium">
152255
<div className="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
@@ -218,6 +321,7 @@ function LoginPage() {
218321
</p>
219322
</div>
220323
</div>
324+
)}
221325
</div>
222326
);
223327
}

0 commit comments

Comments
 (0)