Skip to content

Commit 3fd87b2

Browse files
authored
fix(plugin-auth): route invitation accept link through the Console base path (#2847)
The invitation accept link is now built as ${origin}${uiBasePath}/accept-invitation/<id> — an absolute https origin (getCanonicalOrigin prepends https:// when baseUrl lacks a scheme) plus the Console SPA base (default /_console), matching the frontend router and the Console's own "copy invitation link". Fixes unclickable / 404 invitation links.
1 parent e8cedec commit 3fd87b2

3 files changed

Lines changed: 89 additions & 3 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): invitation accept link is now an absolute URL under the Console base
6+
7+
`sendInvitationEmail` built the accept URL straight from `config.baseUrl` with
8+
no scheme guarantee and no UI mount prefix — `${baseUrl}/accept-invitation/<id>`.
9+
Two problems surfaced in real deployments:
10+
11+
1. When `baseUrl` was a bare host (e.g. `cloud.objectos.ai`, no scheme), the
12+
emailed link was relative-looking; email clients would not linkify it and
13+
clicking it went nowhere.
14+
2. The accept-invitation page is a Console SPA route mounted under `uiBasePath`
15+
(default `/_console`) — the same router/basename as `/login`, `/register`
16+
and `/oauth/consent`, and the exact link the Console itself generates for its
17+
"copy invitation link" action (`${origin}${BASE_URL}accept-invitation/<id>`).
18+
The root-path link omitted that prefix, so it 404'd at the host root instead
19+
of resolving to the page.
20+
21+
The link is now built as
22+
`${origin}${uiBasePath}/accept-invitation/<id>` via a hardened
23+
`getCanonicalOrigin()` that guarantees an absolute origin (prepends `https://`
24+
when `baseUrl` has no scheme). The scheme hardening also applies to the OAuth
25+
issuer / consent / device-flow URLs that share the helper. Deployments that
26+
mount the Console elsewhere are honoured through the existing `uiBasePath`
27+
config.

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,6 +1342,53 @@ describe('AuthManager', () => {
13421342
const { capturedConfig } = await boot({ plugins: { magicLink: true } });
13431343
expect(capturedConfig.plugins.find((p: any) => p.id === 'magic-link')).toBeDefined();
13441344
});
1345+
1346+
// The emailed accept URL must be an absolute link pointing at the Console
1347+
// SPA route, which is mounted under `uiBasePath` (default `/_console`) —
1348+
// the same basename as /login. A bare host in baseUrl (no scheme) also
1349+
// produced relative-looking links email clients wouldn't open.
1350+
it('sendInvitationEmail builds an absolute https:// accept URL under the Console base', async () => {
1351+
const { capturedConfig, emailService } = await boot({ baseUrl: 'cloud.objectos.ai' });
1352+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
1353+
await orgPlugin._opts.sendInvitationEmail({
1354+
email: 'invitee@example.com',
1355+
invitation: { id: 'tok123', organizationId: 'o1', role: 'member' },
1356+
organization: { name: 'Org' },
1357+
inviter: { user: { email: 'admin@example.com' } },
1358+
});
1359+
expect(emailService.sendTemplate).toHaveBeenCalledTimes(1);
1360+
const { data } = emailService.sendTemplate.mock.calls[0][0];
1361+
expect(data.acceptUrl).toBe('https://cloud.objectos.ai/_console/accept-invitation/tok123');
1362+
});
1363+
1364+
it('sendInvitationEmail preserves an explicit scheme in baseUrl', async () => {
1365+
const { capturedConfig, emailService } = await boot({ baseUrl: 'http://localhost:3000/' });
1366+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
1367+
await orgPlugin._opts.sendInvitationEmail({
1368+
email: 'invitee@example.com',
1369+
invitation: { id: 'tok456', organizationId: 'o1', role: 'member' },
1370+
organization: { name: 'Org' },
1371+
inviter: { user: { email: 'admin@example.com' } },
1372+
});
1373+
const { data } = emailService.sendTemplate.mock.calls[0][0];
1374+
expect(data.acceptUrl).toBe('http://localhost:3000/_console/accept-invitation/tok456');
1375+
});
1376+
1377+
it('sendInvitationEmail honours a custom uiBasePath (Console mounted elsewhere)', async () => {
1378+
const { capturedConfig, emailService } = await boot({
1379+
baseUrl: 'https://acme.example.com',
1380+
uiBasePath: '/console/',
1381+
});
1382+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
1383+
await orgPlugin._opts.sendInvitationEmail({
1384+
email: 'invitee@example.com',
1385+
invitation: { id: 'tok789', organizationId: 'o1', role: 'member' },
1386+
organization: { name: 'Org' },
1387+
inviter: { user: { email: 'admin@example.com' } },
1388+
});
1389+
const { data } = emailService.sendTemplate.mock.calls[0][0];
1390+
expect(data.acceptUrl).toBe('https://acme.example.com/console/accept-invitation/tok789');
1391+
});
13451392
});
13461393

13471394
describe('getPublicConfig', () => {

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,8 +1513,15 @@ export class AuthManager {
15131513
// operators / UI can fall back to copy-paste flows. Replace this
15141514
// with a real mail integration when available.
15151515
sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => {
1516-
const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, '');
1517-
const acceptUrl = `${baseUrl}/accept-invitation/${invitation.id}`;
1516+
// The accept-invitation page is a Console SPA route mounted under
1517+
// `uiBasePath` (default `/_console`) — the SAME router/basename as
1518+
// /login, /register and /oauth/consent. The Console builds the very
1519+
// same link for its "copy invitation link" action
1520+
// (`${origin}${BASE_URL}accept-invitation/<id>`), so the emailed link
1521+
// MUST carry that prefix (and an absolute https origin) or it 404s at
1522+
// the host root instead of resolving to the page.
1523+
const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, '');
1524+
const acceptUrl = `${this.getCanonicalOrigin()}${uiBase}/accept-invitation/${invitation.id}`;
15181525
// #2766 V1.5 — placeholder addresses are never real recipients.
15191526
if (isPlaceholderEmail(recipientEmail)) {
15201527
throw new Error(
@@ -2360,7 +2367,12 @@ export class AuthManager {
23602367

23612368
/** Canonical origin of this deployment (config `baseUrl`, auto-detected in dev). */
23622369
private getCanonicalOrigin(): string {
2363-
return (this.config.baseUrl || 'http://localhost:3000').replace(/\/$/, '');
2370+
const raw = (this.config.baseUrl || 'http://localhost:3000').trim().replace(/\/$/, '');
2371+
// Guarantee an absolute origin with a scheme. A bare host (e.g. baseUrl
2372+
// configured as `cloud.objectos.ai`) yields relative-looking links that
2373+
// email clients won't linkify and that break when clicked — prepend
2374+
// https:// so invitation / OAuth URLs open correctly.
2375+
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
23642376
}
23652377

23662378
/**

0 commit comments

Comments
 (0)