Skip to content

Commit ff91895

Browse files
fix(auth,console): register mismatch is a confirm-field error; /register links privacy and terms (#26291)
Fixes #25885 Clause-②: no Metadata-protocol: none ## What changes - **Password mismatch is a confirm-field error** (`ui/packages/auth/src/RegisterForm.tsx`). The form used to show "Passwords do not match" as the `AuthErrorBanner` at the top of the form, above the name field. It now shows it under the confirm-password input. The input carries `aria-invalid="true"` and `aria-describedby="register-confirm-password-error"`, and the message line has `role="alert"`. Editing either password clears the error. The top banner still shows the server's refusals (existing account, closed registration). The wording is the existing `labels.passwordMismatchError`, so no locale key changes. Every page that renders `RegisterForm` gets this, including app-shell's `DefaultRegisterPage`. - **`/register` links privacy and terms** (`ui/apps/console/src/pages/auth/RegisterPage.tsx`). The register page now draws the same footer as `/login`: the locale switch, plus the Privacy and Terms links that `GET /api/v1/auth/config` serves as `features.privacyUrl` / `features.termsUrl`. A link appears only when its URL is served and is an http(s) URL or a same-origin path. - **One reader, not a copy** (`ui/apps/console/src/pages/auth/AuthFooter.tsx`, new). `legalHref`, `readLegalLinks` and the footer component (`LoginFooter`, renamed `AuthFooter`) moved out of `LoginPage.tsx` unchanged. `LoginPage` imports them, and its rendered output is the same: `data-testid="auth-footer"`, classes and link attributes are byte-identical. The login pins in `LoginPage.layout-12600.test.tsx` pass unchanged. ## Tests (head `2d8ec77c`) New pins, written red-first: - `ui/packages/auth/src/__tests__/registerMismatchFieldError-25885.test.tsx` (3 tests): a mismatch sets `aria-invalid` and `aria-describedby` on the confirm input, and the referenced element reads the message. The message comes after the input, no other alert is shown, and sign-up is not sent. Editing the confirmation clears the error. A matching pair stays unmarked, and a server refusal still goes to the banner. - `ui/apps/console/src/pages/auth/__tests__/RegisterPage.legalLinks-25885.test.tsx` (3 tests): Privacy and Terms appear inside `auth-footer` with the served hrefs and `rel=noopener`. No link appears when no URL is served. No link appears for `javascript:` or protocol-relative URLs. Red-first, on the pre-fix tree under the lock: the RegisterForm pin failed 2 of 3 (the preservation test passed, as intended). The RegisterPage pin could not resolve `@objectstack/spec` before the build, so it was NOT MEASURED there. The ablations below are its red. Ablation, one landed mutation per tested behaviour, via `scripts/ablation-replace.mjs` under the lock (anchor 1 → 0, blob moved, restored to the HEAD blob, `git diff HEAD` empty): - RegisterPage without the footer line: `RegisterPage.legalLinks-25885`, **1 failed | 2 passed** (both absence tests stay green by design). - RegisterForm with `setConfirmError` reverted to `setError` (back to the banner): `registerMismatchFieldError-25885`, **2 failed | 1 passed**. Green, under `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0), cwd `ui/`: - `pnpm exec vitest run packages/auth/ apps/console/src/pages/auth/ --maxWorkers=2`: **Test Files 55 passed (55) · Tests 441 passed (441)**. This includes every login-page pin. - `pnpm --filter @object-ui/auth type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0. - `@object-ui/console` `tsc --noEmit`: **NOT MEASURED**. Locally the `@object-ui/*` closure was not built, so all 862 diagnostics were module-resolution errors (TS2307/TS2882/TS7006 fallout). CI's `Type Check UI` measures it. ## Gates (head `2d8ec77c`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands. 34 exited 0, and `--ran` reconciles: "35 derived famil(ies) accounted for — 34 run, 1 NOT-MEASURED". The NOT-MEASURED one is `pnpm check:type-check-debt`, which exited 3 (PREREQUISITE NOT MET). Its `--re-measure` builds every package under the shared lock and never got a turn. This diff touches no tsconfig, typecheck script or ledger. CI's `Lint & Repo Gates` runs it. `node scripts/check-sdui-manifest.mjs`: exit 0. `@object-ui/auth` and `@object-ui/console` have no `scripts/sdui-manifest.sources/*.json` record, so nothing was re-recorded. ## Acceptance notes - **The console's `/register` gets the whole login footer, so it also gains the locale switch.** That is the "same footer" reading of the card. Narrowing it to the two links would need a prop on the shared footer. - **No consent sentence yet.** The card's "ideally" form ("By creating an account you agree to the Terms and Privacy Policy" under the button) is not done. That sentence would be new visible wording, and this claim keeps the locale packs out of scope. The footer reuses the existing `auth.login.footer.privacy` / `.terms` keys, so it is translated today. - **app-shell's default pages draw no legal links.** `DefaultRegisterPage` and `DefaultLoginPage` in `ui/packages/app-shell/src/console/auth/` show none on either page, so they agree with each other. They are not part of this claim. - **Only the mismatch moved.** "Password must be at least 8 characters" stays in the banner. Both inputs carry `minLength={8}`, so the browser's own validation usually stops it first. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 817cb89 commit ff91895

7 files changed

Lines changed: 301 additions & 70 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/auth': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(auth,console): the sign-up form shows a password mismatch under the confirm field, and the register page links privacy and terms
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Mismatch on the field.** `RegisterForm` showed "Passwords do not match" as the error banner at the top of the form, above the name field. The message now sits under the confirm-password input, which carries `aria-invalid` and names the message through `aria-describedby`. Editing either password clears it. The banner still carries what the server refuses (an existing account, a closed registration). The text is the same `labels.passwordMismatchError`, so no locale key changes. Every page that renders `RegisterForm` gets this.
12+
- **Legal links on sign-up.** The console's `/register` now draws the same footer as `/login`: the locale switch, plus the privacy and terms links that `GET /api/v1/auth/config` serves as `features.privacyUrl` / `features.termsUrl`. A link appears only when its URL is served and is an http(s) URL or a same-origin path. Both pages now read the links through one shared reader.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* AuthFooter — the row under the auth card on `/login` and `/register`: the
3+
* locale switch at the left; at the right the privacy and terms links, each
4+
* only when the server's auth config serves its URL.
5+
*
6+
* Lifted out of `LoginPage` unchanged (objectui#12600) so the sign-up page,
7+
* where a visitor accepts the terms, draws the same links from the same reader
8+
* instead of a copy (#25885).
9+
*
10+
* No version or environment pill: the runtime config the preview badge reads
11+
* (`GET /api/v1/runtime/config`) carries the platform stage and no product
12+
* version or environment label, so there is nothing to print (objectui#12600).
13+
*/
14+
15+
import type { AuthPublicConfig } from '@object-ui/auth';
16+
import { useObjectTranslation } from '@object-ui/i18n';
17+
import { LocaleSwitcher } from '@object-ui/app-shell';
18+
19+
/**
20+
* Accept a legal-link URL from the server only as an absolute http(s) URL or a
21+
* same-origin path. Anything else (a `javascript:` URL, a protocol-relative
22+
* `//host`, a non-string) renders no link.
23+
*/
24+
function legalHref(value: unknown): string | undefined {
25+
if (typeof value !== 'string') return undefined;
26+
const href = value.trim();
27+
if (/^https?:\/\//i.test(href)) return href;
28+
if (href.startsWith('/') && !href.startsWith('//')) return href;
29+
return undefined;
30+
}
31+
32+
/**
33+
* The legal links the server's public auth config serves: `features.termsUrl`
34+
* and `features.privacyUrl`, which the spec lists among its non-flag keys
35+
* (`PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS`) and the runtime defaults to its own
36+
* public pages, or omits when an operator blanks them. `AuthPublicConfig` does
37+
* not declare the two keys, so they are read here by name; nothing is
38+
* rendered for a key the server did not send.
39+
*/
40+
export function readLegalLinks(cfg: AuthPublicConfig | null): { privacyUrl?: string; termsUrl?: string } {
41+
const features = cfg?.features as { privacyUrl?: unknown; termsUrl?: unknown } | undefined;
42+
return { privacyUrl: legalHref(features?.privacyUrl), termsUrl: legalHref(features?.termsUrl) };
43+
}
44+
45+
export function AuthFooter({ privacyUrl, termsUrl }: { privacyUrl?: string; termsUrl?: string }) {
46+
const { t } = useObjectTranslation();
47+
const linkClass =
48+
'underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:underline';
49+
return (
50+
<div
51+
data-testid="auth-footer"
52+
className="flex min-h-8 items-center justify-between gap-3 px-1 text-xs text-muted-foreground"
53+
>
54+
<div className="flex items-center">
55+
<LocaleSwitcher />
56+
</div>
57+
{privacyUrl || termsUrl ? (
58+
<span className="inline-flex items-center gap-2.5">
59+
{privacyUrl ? (
60+
<a href={privacyUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
61+
{t('auth.login.footer.privacy', { defaultValue: 'Privacy' })}
62+
</a>
63+
) : null}
64+
{termsUrl ? (
65+
<a href={termsUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
66+
{t('auth.login.footer.terms', { defaultValue: 'Terms' })}
67+
</a>
68+
) : null}
69+
</span>
70+
) : null}
71+
</div>
72+
);
73+
}

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

Lines changed: 2 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,10 @@ import {
5959
isInvitationRedirect,
6060
needsBootstrapProbe,
6161
useBootstrapStatus,
62-
LocaleSwitcher,
6362
InvitationSignUpPrompt,
6463
} from '@object-ui/app-shell';
6564
import { AuthLayout } from './AuthLayout';
65+
import { AuthFooter, readLegalLinks } from './AuthFooter';
6666
import { followOauthAuthorize } from './followAuthorize';
6767
// Was module-private here; lifted to a shared module so `SetupPage` (whose
6868
// first-run exits went without it) and `RegisterPage` (which had copied it)
@@ -80,32 +80,6 @@ interface DevSeedAdmin {
8080
password?: string;
8181
}
8282

83-
/**
84-
* Accept a legal-link URL from the server only as an absolute http(s) URL or a
85-
* same-origin path. Anything else (a `javascript:` URL, a protocol-relative
86-
* `//host`, a non-string) renders no link.
87-
*/
88-
function legalHref(value: unknown): string | undefined {
89-
if (typeof value !== 'string') return undefined;
90-
const href = value.trim();
91-
if (/^https?:\/\//i.test(href)) return href;
92-
if (href.startsWith('/') && !href.startsWith('//')) return href;
93-
return undefined;
94-
}
95-
96-
/**
97-
* The legal links the server's public auth config serves: `features.termsUrl`
98-
* and `features.privacyUrl`, which the spec lists among its non-flag keys
99-
* (`PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS`) and the runtime defaults to its own
100-
* public pages, or omits when an operator blanks them. `AuthPublicConfig` does
101-
* not declare the two keys, so they are read here by name; nothing is
102-
* rendered for a key the server did not send.
103-
*/
104-
function readLegalLinks(cfg: AuthPublicConfig | null): { privacyUrl?: string; termsUrl?: string } {
105-
const features = cfg?.features as { privacyUrl?: unknown; termsUrl?: unknown } | undefined;
106-
return { privacyUrl: legalHref(features?.privacyUrl), termsUrl: legalHref(features?.termsUrl) };
107-
}
108-
10983
function RouterLink(props: { href: string; className?: string; children: React.ReactNode }) {
11084
return (
11185
<Link to={props.href} className={props.className}>
@@ -381,7 +355,7 @@ export function LoginPage() {
381355
</>
382356
)}
383357
</Card>
384-
<LoginFooter privacyUrl={legalLinks.privacyUrl} termsUrl={legalLinks.termsUrl} />
358+
<AuthFooter privacyUrl={legalLinks.privacyUrl} termsUrl={legalLinks.termsUrl} />
385359
</div>
386360
</AuthLayout>
387361
);
@@ -467,44 +441,6 @@ function DevAdminRow({ seed }: { seed: DevSeedAdmin }) {
467441
);
468442
}
469443

470-
/**
471-
* Under the card: the locale switch at the left; at the right the privacy and
472-
* terms links, each only when the server's auth config serves its URL.
473-
*
474-
* No version or environment pill: the runtime config the preview badge reads
475-
* (`GET /api/v1/runtime/config`) carries the platform stage and no product
476-
* version or environment label, so there is nothing to print (objectui#12600).
477-
*/
478-
function LoginFooter({ privacyUrl, termsUrl }: { privacyUrl?: string; termsUrl?: string }) {
479-
const { t } = useObjectTranslation();
480-
const linkClass =
481-
'underline-offset-4 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:underline';
482-
return (
483-
<div
484-
data-testid="auth-footer"
485-
className="flex min-h-8 items-center justify-between gap-3 px-1 text-xs text-muted-foreground"
486-
>
487-
<div className="flex items-center">
488-
<LocaleSwitcher />
489-
</div>
490-
{privacyUrl || termsUrl ? (
491-
<span className="inline-flex items-center gap-2.5">
492-
{privacyUrl ? (
493-
<a href={privacyUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
494-
{t('auth.login.footer.privacy', { defaultValue: 'Privacy' })}
495-
</a>
496-
) : null}
497-
{termsUrl ? (
498-
<a href={termsUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
499-
{t('auth.login.footer.terms', { defaultValue: 'Terms' })}
500-
</a>
501-
) : null}
502-
</span>
503-
) : null}
504-
</div>
505-
);
506-
}
507-
508444
/**
509445
* In place of the form while the server cannot be reached (objectui#11806);
510446
* `RegisterPage` shows the same panel. Built from `@object-ui/auth`'s own form

‎ui/apps/console/src/pages/auth/RegisterPage.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
* verification mail's link so it survives the inbox (objectui#10893).
2424
* - Replays an `/oauth2/authorize` query string when the user landed
2525
* here mid-SSO so the IdP can continue the flow post-signup.
26+
* - Draws `/login`'s footer under the form — the locale switch and the
27+
* privacy / terms links the server's auth config serves, from the same
28+
* reader (#25885): sign-up is where a visitor accepts the terms.
2629
*/
2730

2831
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
@@ -44,6 +47,7 @@ import { followOauthAuthorize } from './followAuthorize';
4447
// implementation — objectui#4181. Behaviour here is unchanged.
4548
import { withConsoleBase, withConsoleBaseRootRelative } from '../../utils/consoleBase';
4649
import { ServerUnreachable } from './LoginPage';
50+
import { AuthFooter, readLegalLinks } from './AuthFooter';
4751

4852
function isSafeRedirect(target: string | null): target is string {
4953
return !!target && target.startsWith('/') && !target.startsWith('//');
@@ -215,6 +219,7 @@ export function RegisterPage() {
215219
}
216220

217221
const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login';
222+
const legalLinks = readLegalLinks(authConfig);
218223
// objectui#10893 — when the server gates sign-in on email verification, the
219224
// mail's link is the only road back, so it must carry `?redirect=` (e.g. the
220225
// invitation the user registered from). Only a safe in-app target is
@@ -318,6 +323,7 @@ export function RegisterPage() {
318323
}}
319324
/>
320325
</Card>
326+
<AuthFooter privacyUrl={legalLinks.privacyUrl} termsUrl={legalLinks.termsUrl} />
321327
</AuthLayout>
322328
);
323329
}
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+
* #25885 — the sign-up page links privacy and terms, as `/login` does.
11+
*
12+
* Sign-up is where a visitor accepts the terms, yet only `/login` drew the
13+
* legal links `GET /api/v1/auth/config` serves as `features.privacyUrl` /
14+
* `features.termsUrl`. The register page now draws the same footer, from the
15+
* same reader, and nothing for a URL the server did not send or that is not
16+
* http(s) / same-origin. Login's own pins stay in
17+
* `LoginPage.layout-12600.test.tsx`.
18+
*/
19+
20+
import { describe, it, expect, vi, afterEach } from 'vitest';
21+
import { render, screen, cleanup } from '@testing-library/react';
22+
import { MemoryRouter } from 'react-router-dom';
23+
import { AuthProvider } from '@object-ui/auth';
24+
import type { AuthClient } from '@object-ui/auth';
25+
import { RegisterPage } from '../RegisterPage';
26+
27+
afterEach(cleanup);
28+
29+
function renderRegister(config: Record<string, unknown>) {
30+
window.history.replaceState({}, '', '/register');
31+
const client = {
32+
getSession: vi.fn().mockResolvedValue(null),
33+
getConfig: vi.fn().mockResolvedValue(config),
34+
} as unknown as AuthClient;
35+
return render(
36+
<AuthProvider authUrl="/api/v1/auth" client={client}>
37+
<MemoryRouter initialEntries={['/register']}>
38+
<RegisterPage />
39+
</MemoryRouter>
40+
</AuthProvider>,
41+
);
42+
}
43+
44+
const LEGAL = {
45+
features: {
46+
privacyUrl: 'https://legal.example.com/privacy',
47+
termsUrl: '/legal/terms',
48+
},
49+
};
50+
51+
describe('RegisterPage — privacy and terms links (#25885)', () => {
52+
it('links privacy and terms in its footer when the auth config serves their URLs', async () => {
53+
renderRegister(LEGAL);
54+
55+
await screen.findByLabelText('Confirm Password');
56+
const footer = screen.getByTestId('auth-footer');
57+
const privacy = screen.getByRole('link', { name: 'Privacy' });
58+
const terms = screen.getByRole('link', { name: 'Terms' });
59+
expect(footer.contains(privacy) && footer.contains(terms)).toBe(true);
60+
expect(privacy.getAttribute('href')).toBe(LEGAL.features.privacyUrl);
61+
expect(terms.getAttribute('href')).toBe(LEGAL.features.termsUrl);
62+
expect(privacy.getAttribute('rel')).toContain('noopener');
63+
});
64+
65+
it('renders no legal link when the auth config serves no URL', async () => {
66+
renderRegister({ features: {} });
67+
68+
await screen.findByLabelText('Confirm Password');
69+
expect(screen.queryByRole('link', { name: 'Privacy' })).toBeNull();
70+
expect(screen.queryByRole('link', { name: 'Terms' })).toBeNull();
71+
});
72+
73+
it('renders no legal link for a URL that is neither http(s) nor a same-origin path', async () => {
74+
renderRegister({
75+
features: { privacyUrl: 'javascript:alert(1)', termsUrl: '//evil.example.com/terms' },
76+
});
77+
78+
await screen.findByLabelText('Confirm Password');
79+
expect(screen.queryByRole('link', { name: 'Privacy' })).toBeNull();
80+
expect(screen.queryByRole('link', { name: 'Terms' })).toBeNull();
81+
});
82+
});

‎ui/packages/auth/src/RegisterForm.tsx‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export interface RegisterFormProps {
9191
hideIcon?: boolean;
9292
}
9393

94+
/** The confirm field's own error line, named by its `aria-describedby`. */
95+
const CONFIRM_ERROR_ID = 'register-confirm-password-error';
96+
9497
const DefaultLink = ({ href, className, children }: AuthLinkComponentProps) => (
9598
<a href={href} className={className}>{children}</a>
9699
);
@@ -146,6 +149,9 @@ export function RegisterForm({
146149
const [password, setPassword] = useState('');
147150
const [confirmPassword, setConfirmPassword] = useState('');
148151
const [error, setError] = useState<string | null>(null);
152+
// #25885 — the mismatch belongs to the confirm field, under it, not to the
153+
// top banner above the first field; the banner keeps the server's refusals.
154+
const [confirmError, setConfirmError] = useState<string | null>(null);
149155

150156
const l = {
151157
nameLabel: labels.nameLabel ?? 'Name',
@@ -167,9 +173,10 @@ export function RegisterForm({
167173
const handleSubmit = async (e: React.FormEvent) => {
168174
e.preventDefault();
169175
setError(null);
176+
setConfirmError(null);
170177

171178
if (password !== confirmPassword) {
172-
setError(l.passwordMismatchError);
179+
setConfirmError(l.passwordMismatchError);
173180
return;
174181
}
175182

@@ -263,7 +270,10 @@ export function RegisterForm({
263270
type="password"
264271
placeholder={l.passwordPlaceholder}
265272
value={password}
266-
onChange={(e) => setPassword(e.target.value)}
273+
onChange={(e) => {
274+
setPassword(e.target.value);
275+
setConfirmError(null);
276+
}}
267277
required
268278
minLength={8}
269279
autoComplete="new-password"
@@ -281,13 +291,27 @@ export function RegisterForm({
281291
type="password"
282292
placeholder={l.confirmPasswordPlaceholder}
283293
value={confirmPassword}
284-
onChange={(e) => setConfirmPassword(e.target.value)}
294+
onChange={(e) => {
295+
setConfirmPassword(e.target.value);
296+
setConfirmError(null);
297+
}}
285298
required
286299
minLength={8}
287300
autoComplete="new-password"
288301
disabled={isLoading}
289-
className={AUTH_INPUT_CLASS}
302+
aria-invalid={confirmError ? true : undefined}
303+
aria-describedby={confirmError ? CONFIRM_ERROR_ID : undefined}
304+
className={
305+
confirmError
306+
? `${AUTH_INPUT_CLASS} border-destructive focus-visible:border-destructive focus-visible:ring-destructive/30`
307+
: AUTH_INPUT_CLASS
308+
}
290309
/>
310+
{confirmError && (
311+
<p id={CONFIRM_ERROR_ID} role="alert" className="text-[13px] font-medium text-destructive">
312+
{confirmError}
313+
</p>
314+
)}
291315
</div>
292316

293317
<button

0 commit comments

Comments
 (0)