Skip to content

Commit 9efaaf7

Browse files
committed
2 parents 4ab16f8 + e4cf774 commit 9efaaf7

10 files changed

Lines changed: 130 additions & 24 deletions

File tree

.changeset/action-param-visible.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
**Action params gain a `visible` predicate; the create-user `phoneNumber` param is gated on `features.phoneNumber`.**
7+
8+
`ActionParamSchema` gains an optional `visible` (CEL, `ExpressionInputSchema`) evaluated against the same scope as action `visible` (`current_user`/`app`/`data`/`features`); a UI that honors it omits the param when it's false. The `sys_user` `create_user` action's `phoneNumber` param now carries `visible: 'features.phoneNumber == true'`, so the form no longer offers a Phone Number field when the opt-in `phoneNumber` auth plugin is off — otherwise the endpoint rejects it with "Phone numbers require the phoneNumber auth plugin". Pairs with the objectui `ActionParamDialog` change that evaluates `param.visible`.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
"@objectstack/client": patch
4+
---
5+
6+
fix(auth): single-source Console page-URL construction; correct SMS + OAuth-callback landing paths
7+
8+
Root-cause hardening after the invitation-link fixes. Every user-facing link
9+
to a Console page is `${origin}${uiBasePath}${path}`, but that composition was
10+
hand-written at each call site — which is how the scheme / `/_console` prefix
11+
kept getting dropped one link at a time.
12+
13+
**plugin-auth**
14+
- New single-source `getConsolePageUrl(path)` helper; `loginPage`,
15+
`consentPage`, device `verificationUri` and the invitation accept URL all
16+
compose through it, so future page links can't drift.
17+
- Phone-invite SMS now links to the actual Console sign-in page
18+
(`${origin}${uiBasePath}/login`) via a new `{{loginUrl}}` template variable
19+
instead of the bare origin. `{{baseUrl}}` is still provided for backward
20+
compatibility with tenant-overridden templates.
21+
22+
**client**
23+
- `signInWithProvider` now defaults `callbackURL` to the current page
24+
(`window.location.href`) instead of a hard-coded `origin + '/login'`. The
25+
SDK cannot know the app's mount path (Console lives under `/_console`), so
26+
returning the user to where they started is the only base-path-correct
27+
default; it also mirrors `linkSocial`. Pass an explicit `callbackURL` to
28+
override.

content/docs/permissions/authentication.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,9 @@ override the env-derived configuration.
409409

410410
```typescript
411411
// Recommended: use the client SDK, which posts to /sign-in/social and redirects.
412+
// `callbackURL` defaults to the current page (window.location.href) — the SDK
413+
// can't know your app's mount path, so it returns you where you started. Pass
414+
// an explicit `callbackURL` to land elsewhere.
412415
await client.auth.signInWithProvider('google');
413416

414417
// Equivalent direct API call: POST /sign-in/social returns a redirect URL.
@@ -417,7 +420,7 @@ const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/social',
417420
headers: { 'Content-Type': 'application/json' },
418421
body: JSON.stringify({
419422
provider: 'google',
420-
callbackURL: window.location.origin + '/login',
423+
callbackURL: window.location.href,
421424
}),
422425
});
423426
const data = await response.json();

packages/client/src/client.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,39 @@ describe('Auth enhancements', () => {
447447
// Token should be auto-set
448448
expect((client as any).token).toBe('refreshed-token');
449449
});
450+
451+
it('signInWithProvider defaults callbackURL to the current page (base-path-correct)', async () => {
452+
const assign = vi.fn();
453+
vi.stubGlobal('window', {
454+
location: { href: 'https://app.example.com/_console/login', assign },
455+
});
456+
try {
457+
const { client, fetchMock } = createMockClient({ url: 'https://accounts.google.com/o/oauth2/auth' });
458+
await client.auth.signInWithProvider('google');
459+
const [, opts] = fetchMock.mock.calls[0];
460+
// The SDK can't know the app's mount path, so it returns the user to
461+
// where they started rather than a hard-coded root '/login'.
462+
expect(JSON.parse(opts.body).callbackURL).toBe('https://app.example.com/_console/login');
463+
expect(assign).toHaveBeenCalledWith('https://accounts.google.com/o/oauth2/auth');
464+
} finally {
465+
vi.unstubAllGlobals();
466+
}
467+
});
468+
469+
it('signInWithProvider honours an explicit callbackURL', async () => {
470+
const assign = vi.fn();
471+
vi.stubGlobal('window', {
472+
location: { href: 'https://app.example.com/_console/login', assign },
473+
});
474+
try {
475+
const { client, fetchMock } = createMockClient({ url: 'https://accounts.google.com/o/oauth2/auth' });
476+
await client.auth.signInWithProvider('google', { callbackURL: 'https://app.example.com/_console/home' });
477+
const [, opts] = fetchMock.mock.calls[0];
478+
expect(JSON.parse(opts.body).callbackURL).toBe('https://app.example.com/_console/home');
479+
} finally {
480+
vi.unstubAllGlobals();
481+
}
482+
});
450483
});
451484

452485
describe('Notifications namespace', () => {

packages/client/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,12 @@ export class ObjectStackClient {
16551655
* - OIDC/enterprise providers: calls POST /sign-in/oauth2 with `{ providerId }`.
16561656
*
16571657
* After the provider callback better-auth sets the session cookie and redirects to `callbackURL`.
1658+
*
1659+
* The default `callbackURL` is the CURRENT page (`window.location.href`),
1660+
* not a hard-coded `/login`: the SDK cannot know the app's mount path (the
1661+
* Console lives under `/_console`, others differ), so returning the user to
1662+
* where they started is the only base-path-correct default. This mirrors
1663+
* `linkSocial`. Pass an explicit `callbackURL` to land somewhere else.
16581664
*/
16591665
signInWithProvider: async (
16601666
provider: string,
@@ -1664,7 +1670,7 @@ export class ObjectStackClient {
16641670
throw new Error('signInWithProvider requires a browser environment');
16651671
}
16661672
const route = this.getRoute('auth');
1667-
const callbackURL = opts?.callbackURL ?? window.location.origin + '/login';
1673+
const callbackURL = opts?.callbackURL ?? window.location.href;
16681674
const isOidc = opts?.type === 'oidc';
16691675
const endpoint = isOidc ? '/sign-in/oauth2' : '/sign-in/social';
16701676
const body: Record<string, string> = isOidc

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ export const SysUser = ObjectSchema.create({
153153
type: 'text',
154154
required: false,
155155
helpText: 'Sign-in phone number (E.164, e.g. +8613800000000). Required when no email is given.',
156+
// Only offer phone when the opt-in phoneNumber auth plugin is loaded —
157+
// otherwise the create-user endpoint rejects a phone with
158+
// "Phone numbers require the phoneNumber auth plugin". `features.phoneNumber`
159+
// is served in /api/v1/auth/config (getPublicConfig).
160+
visible: 'features.phoneNumber == true',
156161
},
157162
{ field: 'name', required: false },
158163
{

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,7 +1251,8 @@ describe('AuthManager', () => {
12511251
expect(sms.sent).toHaveLength(1);
12521252
expect(sms.sent[0].to).toBe(PHONE);
12531253
expect(sms.sent[0].body).toMatch(/verification code/);
1254-
expect(sms.sent[0].body).toContain('http://localhost:3000');
1254+
// Links to the actual Console sign-in page, not the bare origin.
1255+
expect(sms.sent[0].body).toContain('http://localhost:3000/_console/login');
12551256
});
12561257

12571258
// #2815 — localised, tenant-customisable SMS bodies.
@@ -1268,7 +1269,7 @@ describe('AuthManager', () => {
12681269

12691270
await manager.sendPhoneInviteSms(PHONE);
12701271
expect(sms.sent[1].body).toContain('账号已开通');
1271-
expect(sms.sent[1].body).toContain('http://localhost:3000');
1272+
expect(sms.sent[1].body).toContain('http://localhost:3000/_console/login');
12721273
});
12731274

12741275
it('a tenant sys_notification_template row overrides the built-in text', async () => {

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,15 +1516,12 @@ export class AuthManager {
15161516
// operators / UI can fall back to copy-paste flows. Replace this
15171517
// with a real mail integration when available.
15181518
sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => {
1519-
// The accept-invitation page is a Console SPA route mounted under
1520-
// `uiBasePath` (default `/_console`) — the SAME router/basename as
1521-
// /login, /register and /oauth/consent. The Console builds the very
1522-
// same link for its "copy invitation link" action
1523-
// (`${origin}${BASE_URL}accept-invitation/<id>`), so the emailed link
1524-
// MUST carry that prefix (and an absolute https origin) or it 404s at
1525-
// the host root instead of resolving to the page.
1526-
const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, '');
1527-
const acceptUrl = `${this.getCanonicalOrigin()}${uiBase}/accept-invitation/${invitation.id}`;
1519+
// The accept-invitation page is a Console SPA route; the Console
1520+
// builds the very same link for its "copy invitation link" action
1521+
// (`${origin}${BASE_URL}accept-invitation/<id>`). Compose it through
1522+
// the single-source helper so the `/_console` prefix and absolute
1523+
// https origin are guaranteed.
1524+
const acceptUrl = this.getConsolePageUrl(`/accept-invitation/${invitation.id}`);
15281525
// #2766 V1.5 — placeholder addresses are never real recipients.
15291526
if (isPlaceholderEmail(recipientEmail)) {
15301527
throw new Error(
@@ -1723,15 +1720,13 @@ export class AuthManager {
17231720
plugins.push(jwt({ schema: buildJwtPluginSchema() }));
17241721

17251722
const { oauthProvider } = await import('@better-auth/oauth-provider');
1726-
const baseUrl = this.getCanonicalOrigin();
1727-
const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, '');
17281723
const dcr = resolveDcrEnabled(pluginConfig);
17291724
plugins.push(oauthProvider({
17301725
// Console SPA renders both pages (replaces the legacy Account SPA at
17311726
// /_account). Override `uiBasePath` in AuthConfig if Console is
17321727
// mounted elsewhere.
1733-
loginPage: `${baseUrl}${uiBase}/login`,
1734-
consentPage: `${baseUrl}${uiBase}/oauth/consent`,
1728+
loginPage: this.getConsolePageUrl('/login'),
1729+
consentPage: this.getConsolePageUrl('/oauth/consent'),
17351730
schema: buildOauthProviderPluginSchema(),
17361731
// ── MCP OAuth track (#2698) ────────────────────────────────
17371732
// Coarse tool-family scopes for the platform's own MCP endpoint,
@@ -1815,10 +1810,8 @@ export class AuthManager {
18151810
// `verification_uri_complete`.
18161811
if (enabled.deviceAuthorization) {
18171812
const { deviceAuthorization } = await import('better-auth/plugins/device-authorization');
1818-
const baseUrl = this.getCanonicalOrigin();
1819-
const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, '');
18201813
plugins.push(deviceAuthorization({
1821-
verificationUri: `${baseUrl}${uiBase}/auth/device`,
1814+
verificationUri: this.getConsolePageUrl('/auth/device'),
18221815
schema: buildDeviceAuthorizationPluginSchema(),
18231816
}));
18241817
}
@@ -2201,11 +2194,14 @@ export class AuthManager {
22012194
if (!sms) {
22022195
throw new Error('SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.');
22032196
}
2204-
const baseUrl = this.getCanonicalOrigin();
22052197
// #2815 — localised, tenant-customisable body (see deliverPhoneOtp).
2198+
// `loginUrl` points at the actual Console sign-in page; `baseUrl` (bare
2199+
// origin) is kept for backward-compatibility with tenant-overridden
2200+
// templates that still interpolate `{{baseUrl}}`.
22062201
const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.invite, {
22072202
appName: this.getAppName(),
2208-
baseUrl,
2203+
baseUrl: this.getCanonicalOrigin(),
2204+
loginUrl: this.getConsolePageUrl('/login'),
22092205
});
22102206
const result = await sms.send({ to: phone, body, templateParams: { content: body } });
22112207
if (result.status === 'failed') {
@@ -2378,6 +2374,23 @@ export class AuthManager {
23782374
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
23792375
}
23802376

2377+
/**
2378+
* Absolute URL of a page served by the Console SPA. The Console is mounted
2379+
* under `uiBasePath` (default `/_console`) — it owns `/login`, `/register`,
2380+
* `/oauth/consent`, `/auth/device`, `/accept-invitation/:id`, … — so EVERY
2381+
* link we hand to a user (email, SMS, OAuth redirect, device flow) must be
2382+
* `${origin}${uiBasePath}${path}`. This is the single source of truth for
2383+
* that composition: build page links through here, never by hand, so a bare
2384+
* host (missing scheme) or a missing `/_console` prefix can't creep back in
2385+
* one link at a time. Set `uiBasePath` in AuthConfig if Console is mounted
2386+
* elsewhere.
2387+
*/
2388+
private getConsolePageUrl(path: string): string {
2389+
const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, '');
2390+
const rel = path.startsWith('/') ? path : `/${path}`;
2391+
return `${this.getCanonicalOrigin()}${uiBase}${rel}`;
2392+
}
2393+
23812394
/**
23822395
* The OAuth issuer identifier: better-auth's `baseURL` INCLUDING `basePath`
23832396
* (e.g. `https://acme.example.com/api/v1/auth`) — this is the `iss` claim

packages/plugins/plugin-auth/src/phone-sms-texts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const BUILTIN_PHONE_SMS_TEMPLATES: readonly PhoneSmsTemplateRow[] = [
7171
channel: 'sms',
7272
locale: 'en',
7373
subject: 'Account invitation',
74-
body: 'Your {{appName}} account is ready. Sign in with this phone number using a verification code at {{baseUrl}}, then set your password.',
74+
body: 'Your {{appName}} account is ready. Sign in with this phone number using a verification code at {{loginUrl}}, then set your password.',
7575
format: 'text',
7676
is_active: true,
7777
},
@@ -80,7 +80,7 @@ export const BUILTIN_PHONE_SMS_TEMPLATES: readonly PhoneSmsTemplateRow[] = [
8080
channel: 'sms',
8181
locale: 'zh',
8282
subject: '账号邀请',
83-
body: '您的 {{appName}} 账号已开通。请访问 {{baseUrl}},使用本手机号通过验证码登录,然后设置您的密码。',
83+
body: '您的 {{appName}} 账号已开通。请访问 {{loginUrl}},使用本手机号通过验证码登录,然后设置您的密码。',
8484
format: 'text',
8585
is_active: true,
8686
},

packages/spec/src/ui/action.zod.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export const ActionParamSchema = lazySchema(() => z.object({
6666
* context. Useful for edit dialogs that pre-fill from the selected row.
6767
*/
6868
defaultFromRow: z.boolean().optional(),
69+
/**
70+
* Visibility predicate (CEL) — same scope as the action-level `visible`
71+
* (`current_user` / `app` / `data` / `features`). When it evaluates false the
72+
* dialog omits this param entirely. Use it to hide a param that the backend
73+
* only accepts under an opt-in capability, e.g. the create-user `phoneNumber`
74+
* param gated on `features.phoneNumber` so the form never offers a field the
75+
* default backend rejects. Absent = always visible.
76+
*/
77+
visible: ExpressionInputSchema.optional().describe('Param visibility predicate (CEL); omits the param when false.'),
6978
}).refine(
7079
(p) => Boolean(p.name) || Boolean(p.field),
7180
{ message: 'ActionParam requires either "name" or "field"' },

0 commit comments

Comments
 (0)