Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/login-dev-admin-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@object-ui/console": patch
"@object-ui/i18n": patch
---

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.
81 changes: 79 additions & 2 deletions apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ function withConsoleBase(path: string): string {
return base + (path.startsWith('/') ? path : `/${path}`);
}

const DEV_HINT_DISMISSED_KEY = 'os.console.devAdminHintDismissed';

function RouterLink(props: { href: string; className?: string; children: React.ReactNode }) {
return (
<Link to={props.href} className={props.className}>
Expand All @@ -67,6 +69,30 @@ export function LoginPage() {
} = useAuth();

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

// Read public auth config once to know whether sign-up is gated off.
// Read public auth config once to know whether sign-up is gated off and
// whether the dev-seeded admin credentials should be surfaced.
useEffect(() => {
let cancelled = false;
getAuthConfig()
.then((cfg) => {
if (cancelled) return;
setSignUpDisabled(cfg?.emailPassword?.disableSignUp === true);
const seed = (cfg as { devSeedAdmin?: { email?: unknown; password?: unknown } } | null)
?.devSeedAdmin;
setDevSeedAdmin(
seed && typeof seed.email === 'string'
? {
email: seed.email,
password: typeof seed.password === 'string' ? seed.password : undefined,
}
: null,
);
})
.catch(() => {
/* leave default (false) — server-side gate is the source of truth */
/* leave defaults — server-side gate is the source of truth */
});
return () => {
cancelled = true;
Expand Down Expand Up @@ -227,6 +264,46 @@ export function LoginPage() {
</span>
</div>
) : null}
{devSeedAdmin && !devHintDismissed ? (
<div
role="status"
data-testid="dev-admin-hint"
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"
>
<span className="mt-0.5 inline-block size-2 shrink-0 rounded-full bg-amber-500" />
<div className="flex-1">
<div className="font-medium">
{t('auth.login.devAdminHint.title', { defaultValue: 'Development instance' })}
</div>
<div className="text-muted-foreground">
{t('auth.login.devAdminHint.body', {
defaultValue: 'Sign in with the seeded dev admin:',
})}{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
{devSeedAdmin.email}
</code>
{devSeedAdmin.password ? (
<>
{' / '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
{devSeedAdmin.password}
</code>
</>
) : null}
</div>
</div>
<button
type="button"
onClick={dismissDevHint}
aria-label={t('auth.login.devAdminHint.dismiss', { defaultValue: 'Dismiss' })}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-3.5 fill-current">
<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" />
</svg>
</button>
</div>
) : null}
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
<LoginFormCard
registerUrl={signUpDisabled ? undefined : registerUrl}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* LoginPage — dev-seeded admin credentials hint (framework 15.1 third-party
* eval): the runtime seeds `admin@objectos.ai` on an empty dev DB but the
* login page never said so, sending new users to "Sign up" and into an empty
* non-admin workspace. The server exposes `devSeedAdmin` on /auth/config ONLY
* in development while the default password still verifies; the page renders
* it as a dismissible banner and must render nothing when the field is
* absent (production).
*/

import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '@object-ui/auth';
import type { AuthClient } from '@object-ui/auth';
import { LoginPage } from '../LoginPage';

afterEach(cleanup);
beforeEach(() => {
window.localStorage.clear();
});

function createMockClient(config: Record<string, unknown>): AuthClient {
return {
getSession: vi.fn().mockResolvedValue(null),
getConfig: vi.fn().mockResolvedValue(config),
} as unknown as AuthClient;
}

function renderLogin(config: Record<string, unknown>) {
window.history.replaceState({}, '', '/login');
return render(
<AuthProvider authUrl="/api/v1/auth" client={createMockClient(config)}>
<MemoryRouter initialEntries={['/login']}>
<LoginPage />
</MemoryRouter>
</AuthProvider>,
);
}

const DEV_SEED = { devSeedAdmin: { email: 'admin@objectos.ai', password: 'admin123' } };

describe('LoginPage — dev-seeded admin hint', () => {
it('renders the credentials when the server reports devSeedAdmin', async () => {
renderLogin(DEV_SEED);

const hint = await screen.findByTestId('dev-admin-hint');
expect(hint.textContent).toContain('admin@objectos.ai');
expect(hint.textContent).toContain('admin123');
});

it('renders nothing without devSeedAdmin (production config)', async () => {
renderLogin({ emailPassword: { disableSignUp: false } });

// Let the config effect settle, then assert absence.
await waitFor(() => expect(screen.queryByTestId('dev-admin-hint')).toBeNull());
});

it('dismisses on click and stays dismissed via localStorage', async () => {
renderLogin(DEV_SEED);
const user = userEvent.setup();

await screen.findByTestId('dev-admin-hint');
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(screen.queryByTestId('dev-admin-hint')).toBeNull();
expect(window.localStorage.getItem('os.console.devAdminHintDismissed')).toBe('1');

// A re-render (new visit) must respect the stored dismissal.
cleanup();
renderLogin(DEV_SEED);
await waitFor(() => expect(screen.queryByTestId('dev-admin-hint')).toBeNull());
});
});
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1632,6 +1632,11 @@ const en = {
signUpText: 'Sign up',
signingIn: 'Signing you in…',
ssoHandoff: 'Continue to {{target}}',
devAdminHint: {
title: 'Development instance',
body: 'Sign in with the seeded dev admin:',
dismiss: 'Dismiss',
},
errors: {
invalidCredentials: 'Invalid email or password. Please try again.',
emailNotVerified: 'Please verify your email address before signing in.',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1705,6 +1705,11 @@ const zh = {
signUpText: '注册',
signingIn: '正在登录…',
ssoHandoff: '继续前往 {{target}}',
devAdminHint: {
title: '开发实例',
body: '可使用内置的开发管理员登录:',
dismiss: '关闭',
},
errors: {
invalidCredentials: '邮箱或密码错误,请重试。',
emailNotVerified: '请先验证您的邮箱后再登录。',
Expand Down
Loading