diff --git a/.changeset/invitation-accept-url-console-base.md b/.changeset/invitation-accept-url-console-base.md new file mode 100644 index 000000000..4e6c71027 --- /dev/null +++ b/.changeset/invitation-accept-url-console-base.md @@ -0,0 +1,27 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): invitation accept link is now an absolute URL under the Console base + +`sendInvitationEmail` built the accept URL straight from `config.baseUrl` with +no scheme guarantee and no UI mount prefix — `${baseUrl}/accept-invitation/`. +Two problems surfaced in real deployments: + +1. When `baseUrl` was a bare host (e.g. `cloud.objectos.ai`, no scheme), the + emailed link was relative-looking; email clients would not linkify it and + clicking it went nowhere. +2. The accept-invitation page is a Console SPA route mounted under `uiBasePath` + (default `/_console`) — the same router/basename as `/login`, `/register` + and `/oauth/consent`, and the exact link the Console itself generates for its + "copy invitation link" action (`${origin}${BASE_URL}accept-invitation/`). + The root-path link omitted that prefix, so it 404'd at the host root instead + of resolving to the page. + +The link is now built as +`${origin}${uiBasePath}/accept-invitation/` via a hardened +`getCanonicalOrigin()` that guarantees an absolute origin (prepends `https://` +when `baseUrl` has no scheme). The scheme hardening also applies to the OAuth +issuer / consent / device-flow URLs that share the helper. Deployments that +mount the Console elsewhere are honoured through the existing `uiBasePath` +config. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index ba1adb12c..64ad82fc9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1342,6 +1342,53 @@ describe('AuthManager', () => { const { capturedConfig } = await boot({ plugins: { magicLink: true } }); expect(capturedConfig.plugins.find((p: any) => p.id === 'magic-link')).toBeDefined(); }); + + // The emailed accept URL must be an absolute link pointing at the Console + // SPA route, which is mounted under `uiBasePath` (default `/_console`) — + // the same basename as /login. A bare host in baseUrl (no scheme) also + // produced relative-looking links email clients wouldn't open. + it('sendInvitationEmail builds an absolute https:// accept URL under the Console base', async () => { + const { capturedConfig, emailService } = await boot({ baseUrl: 'cloud.objectos.ai' }); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await orgPlugin._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'tok123', organizationId: 'o1', role: 'member' }, + organization: { name: 'Org' }, + inviter: { user: { email: 'admin@example.com' } }, + }); + expect(emailService.sendTemplate).toHaveBeenCalledTimes(1); + const { data } = emailService.sendTemplate.mock.calls[0][0]; + expect(data.acceptUrl).toBe('https://cloud.objectos.ai/_console/accept-invitation/tok123'); + }); + + it('sendInvitationEmail preserves an explicit scheme in baseUrl', async () => { + const { capturedConfig, emailService } = await boot({ baseUrl: 'http://localhost:3000/' }); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await orgPlugin._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'tok456', organizationId: 'o1', role: 'member' }, + organization: { name: 'Org' }, + inviter: { user: { email: 'admin@example.com' } }, + }); + const { data } = emailService.sendTemplate.mock.calls[0][0]; + expect(data.acceptUrl).toBe('http://localhost:3000/_console/accept-invitation/tok456'); + }); + + it('sendInvitationEmail honours a custom uiBasePath (Console mounted elsewhere)', async () => { + const { capturedConfig, emailService } = await boot({ + baseUrl: 'https://acme.example.com', + uiBasePath: '/console/', + }); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await orgPlugin._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'tok789', organizationId: 'o1', role: 'member' }, + organization: { name: 'Org' }, + inviter: { user: { email: 'admin@example.com' } }, + }); + const { data } = emailService.sendTemplate.mock.calls[0][0]; + expect(data.acceptUrl).toBe('https://acme.example.com/console/accept-invitation/tok789'); + }); }); describe('getPublicConfig', () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 5e496f5d1..19524427c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1513,8 +1513,15 @@ export class AuthManager { // operators / UI can fall back to copy-paste flows. Replace this // with a real mail integration when available. sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => { - const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); - const acceptUrl = `${baseUrl}/accept-invitation/${invitation.id}`; + // The accept-invitation page is a Console SPA route mounted under + // `uiBasePath` (default `/_console`) — the SAME router/basename as + // /login, /register and /oauth/consent. The Console builds the very + // same link for its "copy invitation link" action + // (`${origin}${BASE_URL}accept-invitation/`), so the emailed link + // MUST carry that prefix (and an absolute https origin) or it 404s at + // the host root instead of resolving to the page. + const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); + const acceptUrl = `${this.getCanonicalOrigin()}${uiBase}/accept-invitation/${invitation.id}`; // #2766 V1.5 — placeholder addresses are never real recipients. if (isPlaceholderEmail(recipientEmail)) { throw new Error( @@ -2360,7 +2367,12 @@ export class AuthManager { /** Canonical origin of this deployment (config `baseUrl`, auto-detected in dev). */ private getCanonicalOrigin(): string { - return (this.config.baseUrl || 'http://localhost:3000').replace(/\/$/, ''); + const raw = (this.config.baseUrl || 'http://localhost:3000').trim().replace(/\/$/, ''); + // Guarantee an absolute origin with a scheme. A bare host (e.g. baseUrl + // configured as `cloud.objectos.ai`) yields relative-looking links that + // email clients won't linkify and that break when clicked — prepend + // https:// so invitation / OAuth URLs open correctly. + return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; } /**