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
27 changes: 27 additions & 0 deletions .changeset/invitation-accept-url-console-base.md
Original file line number Diff line number Diff line change
@@ -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/<id>`.
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/<id>`).
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/<id>` 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.
47 changes: 47 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
18 changes: 15 additions & 3 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`), 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(
Expand Down Expand Up @@ -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}`;
}

/**
Expand Down