Skip to content

Commit be3fbfa

Browse files
os-zhuangCopilot
andcommitted
fix(auth): UX polish + auto-join personal org on SSO user create
- AuthShell now shows a tenant-host pill on non-cloud hosts so users can tell whether they're on cloud.objectos.app or demo.objectos.app - Account UserMenu: instant TransitionOverlay on sign-out + hard nav to /_account/login so logout never looks like a no-op - Console: SignOutOverlay watches useAuth() and paints a full-screen spinner during the authed→null transition (UserMenu lives in published app-shell, so we react to state instead of wrapping the click) - Login: TransitionOverlay shows while await client.auth.login resolves - plugin-auth: pass-through databaseHooks to better-auth config - runtime: wire user.create.after hook → ensureUserHasOrganization so SSO-created users land on /home instead of /organizations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f0f7c27 commit be3fbfa

12 files changed

Lines changed: 324 additions & 46 deletions

File tree

apps/account/src/components/auth/auth-shell.tsx

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
*
1414
* On < lg the brand panel is hidden and a compact brand tile sits above the
1515
* form so the page stays cohesive on mobile.
16+
*
17+
* **Tenant pill.** The whole Account SPA is shipped verbatim with every
18+
* objectos tenant subdomain AND with the cloud control-plane, so the
19+
* login pages on `cloud.objectos.app` and `demo.objectos.app` would
20+
* otherwise be byte-identical. When the form is rendered on a host
21+
* other than the canonical cloud host, AuthShell surfaces a small pill
22+
* with the current hostname under the brand mark, so users coming back
23+
* from an SSO bounce can tell which app they're about to sign in to.
24+
* It also overrides `document.title` to include the host.
1625
*/
1726

1827
import * as React from 'react';
@@ -31,6 +40,23 @@ export interface AuthShellProps {
3140
formWidth?: 'sm' | 'md';
3241
}
3342

43+
/**
44+
* Hosts that ARE the canonical cloud control-plane — for these we hide the
45+
* tenant pill because the brand wordmark + page chrome is already the
46+
* identifier. Matches `cloud.<root>` for the common public domain and
47+
* `cloud.localhost` (or `cloud.localhost:NNNN`) for local dev.
48+
*/
49+
function isCanonicalCloudHost(host: string): boolean {
50+
const bare = host.split(':')[0]!.toLowerCase();
51+
return /^cloud\./.test(bare);
52+
}
53+
54+
function currentHost(): string | null {
55+
if (typeof window === 'undefined') return null;
56+
const host = window.location.host;
57+
return host || null;
58+
}
59+
3460
export function AuthShell({
3561
children,
3662
headline,
@@ -39,20 +65,45 @@ export function AuthShell({
3965
}: AuthShellProps) {
4066
const { t } = useObjectTranslation();
4167
const widthCls = formWidth === 'md' ? 'max-w-md' : 'max-w-sm';
68+
69+
const host = currentHost();
70+
const showTenantPill = !!host && !isCanonicalCloudHost(host);
71+
72+
React.useEffect(() => {
73+
if (typeof document === 'undefined' || !host) return;
74+
const original = document.title;
75+
document.title = showTenantPill ? `${host} · ObjectStack` : original;
76+
return () => {
77+
document.title = original;
78+
};
79+
}, [host, showTenantPill]);
80+
4281
return (
4382
<div className="relative grid min-h-svh w-full lg:grid-cols-2">
4483
{/* Left: form column */}
4584
<div className="flex flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
4685
<div className={cn('flex w-full flex-col gap-6', widthCls)}>
47-
<a
48-
href="#"
49-
className="flex items-center gap-2 self-center font-semibold tracking-tight"
50-
>
51-
<div className="flex size-7 items-center justify-center rounded-md bg-brand-gradient text-primary-foreground shadow-sm shadow-primary/30">
52-
<GalleryVerticalEnd className="size-4" />
53-
</div>
54-
<span>ObjectStack</span>
55-
</a>
86+
<div className="flex flex-col items-center gap-1 self-center">
87+
<a
88+
href="#"
89+
className="flex items-center gap-2 font-semibold tracking-tight"
90+
>
91+
<div className="flex size-7 items-center justify-center rounded-md bg-brand-gradient text-primary-foreground shadow-sm shadow-primary/30">
92+
<GalleryVerticalEnd className="size-4" />
93+
</div>
94+
<span>ObjectStack</span>
95+
</a>
96+
{showTenantPill ? (
97+
<span
98+
className="rounded-full border border-border/60 bg-muted/50 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground"
99+
title={t('auth.shell.tenantHostHint', {
100+
defaultValue: 'You are signing in to this workspace',
101+
})}
102+
>
103+
{host}
104+
</span>
105+
) : null}
106+
</div>
56107
{children}
57108
</div>
58109
</div>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* TransitionOverlay — full-page semi-transparent backdrop with a spinner
5+
* and a short status message. Used whenever we kick off a navigation
6+
* that will unload the current document (SSO bounce, logout, post-login
7+
* redirect to another SPA) so the user gets unmistakable feedback that
8+
* the click did fire, especially on slow networks where the bare button
9+
* would look broken.
10+
*
11+
* Rendered via a portal into `document.body` so it floats above every
12+
* Card / Dialog without z-index gymnastics.
13+
*/
14+
15+
import * as React from 'react';
16+
import { createPortal } from 'react-dom';
17+
18+
export interface TransitionOverlayProps {
19+
/** Status text shown next to the spinner. */
20+
message: string;
21+
}
22+
23+
export function TransitionOverlay({ message }: TransitionOverlayProps) {
24+
if (typeof document === 'undefined') return null;
25+
return createPortal(
26+
<div
27+
role="status"
28+
aria-live="polite"
29+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm"
30+
>
31+
<div className="flex flex-col items-center gap-3 text-sm text-muted-foreground">
32+
<div className="size-8 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
33+
<span>{message}</span>
34+
</div>
35+
</div>,
36+
document.body,
37+
);
38+
}

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

Lines changed: 56 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { useNavigate } from '@tanstack/react-router';
9+
import { useState } from 'react';
910
import { LogOut, User as UserIcon, Building2 } from 'lucide-react';
1011
import { useObjectTranslation } from '@object-ui/i18n';
1112
import {
@@ -19,6 +20,7 @@ import {
1920
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
2021
import { Button } from '@/components/ui/button';
2122
import { useSession } from '@/hooks/useSession';
23+
import { TransitionOverlay } from '@/components/auth/transition-overlay';
2224

2325
function initials(name?: string | null, email?: string | null): string {
2426
const src = (name || email || '?').trim();
@@ -31,6 +33,7 @@ export function UserMenu() {
3133
const { t } = useObjectTranslation();
3234
const navigate = useNavigate();
3335
const { user, loading, logout } = useSession();
36+
const [signingOut, setSigningOut] = useState(false);
3437

3538
if (loading && !user) {
3639
return <div className="h-8 w-8 animate-pulse rounded-full bg-muted" aria-hidden />;
@@ -50,49 +53,65 @@ export function UserMenu() {
5053
}
5154

5255
const handleLogout = async () => {
56+
// Show overlay *immediately* so the click registers visually even if
57+
// `logout()` and the subsequent navigation take a second or two on a
58+
// slow link. Without this the dropdown closes, the page stays put,
59+
// and the user thinks "nothing happened".
60+
setSigningOut(true);
5361
try {
5462
await logout();
5563
} finally {
56-
navigate({ to: '/login', replace: true });
64+
// Hard-navigate. We deliberately don't use the SPA router: the
65+
// session-cleared state still has stale Account-side data, and a
66+
// full reload guarantees every consumer (incl. the embedded
67+
// Console iframe / sibling SPAs) starts from a clean slate.
68+
window.location.assign('/_account/login');
5769
}
5870
};
5971

6072
return (
61-
<DropdownMenu>
62-
<DropdownMenuTrigger asChild>
63-
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
64-
<Avatar className="h-7 w-7">
65-
{user.image ? <AvatarImage src={user.image} alt={user.name ?? user.email ?? t('userMenu.user')} /> : null}
66-
<AvatarFallback className="text-[11px]">
67-
{initials(user.name, user.email)}
68-
</AvatarFallback>
69-
</Avatar>
70-
</Button>
71-
</DropdownMenuTrigger>
72-
<DropdownMenuContent align="end" className="w-64">
73-
<DropdownMenuLabel>
74-
<div className="flex flex-col gap-0.5">
75-
<span className="truncate text-sm font-medium">{user.name || user.email}</span>
76-
{user.email && (
77-
<span className="truncate text-[11px] text-muted-foreground">{user.email}</span>
78-
)}
79-
</div>
80-
</DropdownMenuLabel>
81-
<DropdownMenuSeparator />
82-
<DropdownMenuItem onSelect={() => navigate({ to: '/account' })}>
83-
<UserIcon className="mr-2 h-3.5 w-3.5" />
84-
{t('userMenu.account')}
85-
</DropdownMenuItem>
86-
<DropdownMenuItem onSelect={() => navigate({ to: '/organizations' })}>
87-
<Building2 className="mr-2 h-3.5 w-3.5" />
88-
{t('userMenu.organizations')}
89-
</DropdownMenuItem>
90-
<DropdownMenuSeparator />
91-
<DropdownMenuItem onSelect={handleLogout}>
92-
<LogOut className="mr-2 h-3.5 w-3.5" />
93-
{t('userMenu.signOut')}
94-
</DropdownMenuItem>
95-
</DropdownMenuContent>
96-
</DropdownMenu>
73+
<>
74+
<DropdownMenu>
75+
<DropdownMenuTrigger asChild>
76+
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
77+
<Avatar className="h-7 w-7">
78+
{user.image ? <AvatarImage src={user.image} alt={user.name ?? user.email ?? t('userMenu.user')} /> : null}
79+
<AvatarFallback className="text-[11px]">
80+
{initials(user.name, user.email)}
81+
</AvatarFallback>
82+
</Avatar>
83+
</Button>
84+
</DropdownMenuTrigger>
85+
<DropdownMenuContent align="end" className="w-64">
86+
<DropdownMenuLabel>
87+
<div className="flex flex-col gap-0.5">
88+
<span className="truncate text-sm font-medium">{user.name || user.email}</span>
89+
{user.email && (
90+
<span className="truncate text-[11px] text-muted-foreground">{user.email}</span>
91+
)}
92+
</div>
93+
</DropdownMenuLabel>
94+
<DropdownMenuSeparator />
95+
<DropdownMenuItem onSelect={() => navigate({ to: '/account' })}>
96+
<UserIcon className="mr-2 h-3.5 w-3.5" />
97+
{t('userMenu.account')}
98+
</DropdownMenuItem>
99+
<DropdownMenuItem onSelect={() => navigate({ to: '/organizations' })}>
100+
<Building2 className="mr-2 h-3.5 w-3.5" />
101+
{t('userMenu.organizations')}
102+
</DropdownMenuItem>
103+
<DropdownMenuSeparator />
104+
<DropdownMenuItem onSelect={handleLogout} disabled={signingOut}>
105+
<LogOut className="mr-2 h-3.5 w-3.5" />
106+
{t('userMenu.signOut')}
107+
</DropdownMenuItem>
108+
</DropdownMenuContent>
109+
</DropdownMenu>
110+
{signingOut ? (
111+
<TransitionOverlay
112+
message={t('auth.userMenu.signingOut', { defaultValue: 'Signing you out…' })}
113+
/>
114+
) : null}
115+
</>
97116
);
98117
}

apps/account/src/i18n/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@
346346
"deny": "Deny",
347347
"denying": "Denying…",
348348
"cancel": "Cancel"
349+
},
350+
"shell": {
351+
"tenantHostHint": "You are signing in to this workspace"
352+
},
353+
"userMenu": {
354+
"signingOut": "Signing you out…"
349355
}
350356
},
351357
"acceptInvitation": {

apps/account/src/i18n/locales/zh-CN.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@
346346
"deny": "拒绝",
347347
"denying": "拒绝中…",
348348
"cancel": "取消"
349+
},
350+
"shell": {
351+
"tenantHostHint": "你正在登录此工作区"
352+
},
353+
"userMenu": {
354+
"signingOut": "正在退出…"
349355
}
350356
},
351357
"acceptInvitation": {

apps/account/src/routes/login.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { toast } from '@/hooks/use-toast';
1212
import { useSession } from '@/hooks/useSession';
1313
import { SocialSignInButtons } from '@/components/auth/social-sign-in-buttons';
1414
import { AuthShell } from '@/components/auth/auth-shell';
15+
import { TransitionOverlay } from '@/components/auth/transition-overlay';
1516

1617
export const Route = createFileRoute('/login')({
1718
validateSearch: (
@@ -188,6 +189,11 @@ function LoginPage() {
188189

189190
return (
190191
<AuthShell>
192+
{submitting ? (
193+
<TransitionOverlay
194+
message={t('auth.login.signingIn', { defaultValue: 'Signing you in…' })}
195+
/>
196+
) : null}
191197
<div className="flex flex-col gap-6">
192198
{ssoTarget ? (
193199
<div

apps/console/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { AccountLoginRedirect } from './components/AccountLoginRedirect';
2828
import { CloudAwareRootRedirect } from './components/CloudAwareRootRedirect';
2929
import { FormPage } from './components/FormPage';
3030
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
31+
import { SignOutOverlay } from './components/SignOutOverlay';
3132
import {
3233
gotoAccountLogin,
3334
gotoAccountRegister,
@@ -166,6 +167,7 @@ export function App() {
166167
<UploadProvider adapter={uploadAdapter}>
167168
<Toaster position="bottom-right" />
168169
<MetadataHmrReloader />
170+
<SignOutOverlay />
169171
<BrowserRouter basename={BASENAME}>
170172
<ConsoleShell>
171173
<Routes>

0 commit comments

Comments
 (0)