Skip to content

Commit ca0f5f0

Browse files
os-zhuangclaude
andauthored
feat(console): dev-seeded admin credentials hint on the login page (#2635)
The framework seeds admin@objectos.ai/admin123 on an empty dev DB, but the login page gave no signal it exists — new users signed up and fell into an empty non-admin workspace (framework 15.1 third-party eval P2). Render a dismissible amber banner above the login card when /api/v1/auth/config reports devSeedAdmin. The field is server-gated: present only under NODE_ENV=development while the seed account still verifies against the default password (framework#3108), so production can never show it. Dismissal persists per browser (os.console.devAdminHintDismissed). Browser-verified against a real dev backend: banner renders (zh locale), dismiss hides + survives reload, clearing the flag restores it; absent config renders nothing. 3 new vitest cases. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 022735f commit ca0f5f0

5 files changed

Lines changed: 177 additions & 2 deletions

File tree

.changeset/login-dev-admin-hint.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@object-ui/console": patch
3+
"@object-ui/i18n": patch
4+
---
5+
6+
Login page surfaces the dev-seeded admin credentials. The framework runtime seeds `admin@objectos.ai` on an empty development database, but nothing on the login page said so — new users clicked "Sign up" and landed in an empty non-admin workspace (15.1 third-party eval). When `GET /api/v1/auth/config` reports `devSeedAdmin` (dev-only; the server omits the field in production and once the default password is changed), the page renders a dismissible amber banner with the credentials. Dismissal persists per browser via localStorage.

apps/console/src/pages/auth/LoginPage.tsx

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ function withConsoleBase(path: string): string {
4444
return base + (path.startsWith('/') ? path : `/${path}`);
4545
}
4646

47+
const DEV_HINT_DISMISSED_KEY = 'os.console.devAdminHintDismissed';
48+
4749
function RouterLink(props: { href: string; className?: string; children: React.ReactNode }) {
4850
return (
4951
<Link to={props.href} className={props.className}>
@@ -67,6 +69,30 @@ export function LoginPage() {
6769
} = useAuth();
6870

6971
const [signUpDisabled, setSignUpDisabled] = useState(false);
72+
// Dev-only seeded-admin hint (15.1 third-party eval): the runtime seeds
73+
// admin@objectos.ai on an empty dev DB, but nothing on this page said so —
74+
// new users clicked "Sign up" and landed in an empty non-admin workspace.
75+
// The server reports the credentials via /auth/config `devSeedAdmin` ONLY
76+
// in development while the account still carries the default password, so
77+
// production can never render this. Dismissal is remembered per browser.
78+
const [devSeedAdmin, setDevSeedAdmin] = useState<{ email: string; password?: string } | null>(
79+
null,
80+
);
81+
const [devHintDismissed, setDevHintDismissed] = useState<boolean>(() => {
82+
try {
83+
return window.localStorage.getItem(DEV_HINT_DISMISSED_KEY) === '1';
84+
} catch {
85+
return false;
86+
}
87+
});
88+
const dismissDevHint = () => {
89+
setDevHintDismissed(true);
90+
try {
91+
window.localStorage.setItem(DEV_HINT_DISMISSED_KEY, '1');
92+
} catch {
93+
/* private mode — hide for this session only */
94+
}
95+
};
7096
const [autoSelectingOrg, setAutoSelectingOrg] = useState(false);
7197
// The OAuth hand-off fetch must fire at most once even though the post-login
7298
// effect re-runs as org state settles (it navigates away on success).
@@ -111,16 +137,27 @@ export function LoginPage() {
111137
// eslint-disable-next-line react-hooks/exhaustive-deps
112138
}, []);
113139

114-
// Read public auth config once to know whether sign-up is gated off.
140+
// Read public auth config once to know whether sign-up is gated off and
141+
// whether the dev-seeded admin credentials should be surfaced.
115142
useEffect(() => {
116143
let cancelled = false;
117144
getAuthConfig()
118145
.then((cfg) => {
119146
if (cancelled) return;
120147
setSignUpDisabled(cfg?.emailPassword?.disableSignUp === true);
148+
const seed = (cfg as { devSeedAdmin?: { email?: unknown; password?: unknown } } | null)
149+
?.devSeedAdmin;
150+
setDevSeedAdmin(
151+
seed && typeof seed.email === 'string'
152+
? {
153+
email: seed.email,
154+
password: typeof seed.password === 'string' ? seed.password : undefined,
155+
}
156+
: null,
157+
);
121158
})
122159
.catch(() => {
123-
/* leave default (false) — server-side gate is the source of truth */
160+
/* leave defaults — server-side gate is the source of truth */
124161
});
125162
return () => {
126163
cancelled = true;
@@ -227,6 +264,46 @@ export function LoginPage() {
227264
</span>
228265
</div>
229266
) : null}
267+
{devSeedAdmin && !devHintDismissed ? (
268+
<div
269+
role="status"
270+
data-testid="dev-admin-hint"
271+
className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-foreground"
272+
>
273+
<span className="mt-0.5 inline-block size-2 shrink-0 rounded-full bg-amber-500" />
274+
<div className="flex-1">
275+
<div className="font-medium">
276+
{t('auth.login.devAdminHint.title', { defaultValue: 'Development instance' })}
277+
</div>
278+
<div className="text-muted-foreground">
279+
{t('auth.login.devAdminHint.body', {
280+
defaultValue: 'Sign in with the seeded dev admin:',
281+
})}{' '}
282+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
283+
{devSeedAdmin.email}
284+
</code>
285+
{devSeedAdmin.password ? (
286+
<>
287+
{' / '}
288+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
289+
{devSeedAdmin.password}
290+
</code>
291+
</>
292+
) : null}
293+
</div>
294+
</div>
295+
<button
296+
type="button"
297+
onClick={dismissDevHint}
298+
aria-label={t('auth.login.devAdminHint.dismiss', { defaultValue: 'Dismiss' })}
299+
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
300+
>
301+
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-3.5 fill-current">
302+
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
303+
</svg>
304+
</button>
305+
</div>
306+
) : null}
230307
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
231308
<LoginFormCard
232309
registerUrl={signUpDisabled ? undefined : registerUrl}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* LoginPage — dev-seeded admin credentials hint (framework 15.1 third-party
11+
* eval): the runtime seeds `admin@objectos.ai` on an empty dev DB but the
12+
* login page never said so, sending new users to "Sign up" and into an empty
13+
* non-admin workspace. The server exposes `devSeedAdmin` on /auth/config ONLY
14+
* in development while the default password still verifies; the page renders
15+
* it as a dismissible banner and must render nothing when the field is
16+
* absent (production).
17+
*/
18+
19+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
20+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
21+
import userEvent from '@testing-library/user-event';
22+
import { MemoryRouter } from 'react-router-dom';
23+
import { AuthProvider } from '@object-ui/auth';
24+
import type { AuthClient } from '@object-ui/auth';
25+
import { LoginPage } from '../LoginPage';
26+
27+
afterEach(cleanup);
28+
beforeEach(() => {
29+
window.localStorage.clear();
30+
});
31+
32+
function createMockClient(config: Record<string, unknown>): AuthClient {
33+
return {
34+
getSession: vi.fn().mockResolvedValue(null),
35+
getConfig: vi.fn().mockResolvedValue(config),
36+
} as unknown as AuthClient;
37+
}
38+
39+
function renderLogin(config: Record<string, unknown>) {
40+
window.history.replaceState({}, '', '/login');
41+
return render(
42+
<AuthProvider authUrl="/api/v1/auth" client={createMockClient(config)}>
43+
<MemoryRouter initialEntries={['/login']}>
44+
<LoginPage />
45+
</MemoryRouter>
46+
</AuthProvider>,
47+
);
48+
}
49+
50+
const DEV_SEED = { devSeedAdmin: { email: 'admin@objectos.ai', password: 'admin123' } };
51+
52+
describe('LoginPage — dev-seeded admin hint', () => {
53+
it('renders the credentials when the server reports devSeedAdmin', async () => {
54+
renderLogin(DEV_SEED);
55+
56+
const hint = await screen.findByTestId('dev-admin-hint');
57+
expect(hint.textContent).toContain('admin@objectos.ai');
58+
expect(hint.textContent).toContain('admin123');
59+
});
60+
61+
it('renders nothing without devSeedAdmin (production config)', async () => {
62+
renderLogin({ emailPassword: { disableSignUp: false } });
63+
64+
// Let the config effect settle, then assert absence.
65+
await waitFor(() => expect(screen.queryByTestId('dev-admin-hint')).toBeNull());
66+
});
67+
68+
it('dismisses on click and stays dismissed via localStorage', async () => {
69+
renderLogin(DEV_SEED);
70+
const user = userEvent.setup();
71+
72+
await screen.findByTestId('dev-admin-hint');
73+
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
74+
expect(screen.queryByTestId('dev-admin-hint')).toBeNull();
75+
expect(window.localStorage.getItem('os.console.devAdminHintDismissed')).toBe('1');
76+
77+
// A re-render (new visit) must respect the stored dismissal.
78+
cleanup();
79+
renderLogin(DEV_SEED);
80+
await waitFor(() => expect(screen.queryByTestId('dev-admin-hint')).toBeNull());
81+
});
82+
});

packages/i18n/src/locales/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,6 +1632,11 @@ const en = {
16321632
signUpText: 'Sign up',
16331633
signingIn: 'Signing you in…',
16341634
ssoHandoff: 'Continue to {{target}}',
1635+
devAdminHint: {
1636+
title: 'Development instance',
1637+
body: 'Sign in with the seeded dev admin:',
1638+
dismiss: 'Dismiss',
1639+
},
16351640
errors: {
16361641
invalidCredentials: 'Invalid email or password. Please try again.',
16371642
emailNotVerified: 'Please verify your email address before signing in.',

packages/i18n/src/locales/zh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,6 +1705,11 @@ const zh = {
17051705
signUpText: '注册',
17061706
signingIn: '正在登录…',
17071707
ssoHandoff: '继续前往 {{target}}',
1708+
devAdminHint: {
1709+
title: '开发实例',
1710+
body: '可使用内置的开发管理员登录:',
1711+
dismiss: '关闭',
1712+
},
17081713
errors: {
17091714
invalidCredentials: '邮箱或密码错误,请重试。',
17101715
emailNotVerified: '请先验证您的邮箱后再登录。',

0 commit comments

Comments
 (0)