feat(workspaces): accept-invite URL handler (slice 2C) + email URL path fix

Slice 2C: hash route #/accept-invite/{id} with full flow support across
all six auth entry points (login/register/Google/Microsoft/support/setup)
via app-boot consumer pattern rather than per-handler hooks. Stash
mechanism uses localStorage with timestamp + staleness check
(INVITE_EXPIRY_DAYS_FRONTEND = 7, mirrors backend default). On success:
switch workspace, reload, show toast post-reload via scoped
pending_invite_toast key. On error: showToast directly, no reload.
Non-reentrant guard prevents double-consume across the synthetic
hashchange that fires before reload completes.

Two bugs surfaced during Playwright-driven verification (slice 1 left
two latent issues that only manifested when the full accept-invite
flow ran end-to-end):

1. Email URL path: workspaces.js constructed
   ${publicBase}/#/accept-invite/X which lands on the marketing landing
   page (the SPA is at /app). Fixed to use
   ${publicBase}/app#/accept-invite/X. Any invite email sent before
   this fix would have produced an unfollowable link.

2. Synchronous hashchange race: location.hash = '#/' followed by
   reload() fires hashchange BEFORE the reload unloads the page. The
   intermediate route() call would consume the toast key against a DOM
   about to be destroyed, so the post-reload page had no toast. Fixed
   with history.replaceState which mutates hash without firing
   hashchange.

Files:
- server/routes/workspaces.js (+4/-1, /app path fix + comment)
- frontend/js/api.js (+3 LOC, acceptInvite helper)
- frontend/js/app.js (+154 LOC, accept-invite plumbing)
- frontend/js/i18n/en.js (+9 LOC, accept.* keys)

Browser verification: 11/11 assertions PASS via Playwright suite
covering all 5 D-cases (unauthed flow, authed direct, wrong account,
stale stash, already-member). Script stashed at
~/Documents/screentinker-2c-playwright-2026-05.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-05-16 13:50:23 -05:00
parent 8db171d979
commit 399af54839
4 changed files with 170 additions and 1 deletions

View file

@ -163,6 +163,9 @@ export const api = {
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`),
// Slice 2C - accept a workspace invite by id (post-auth flow)
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
// Admin - Users
getUsers: () => request('/auth/users'),
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),

View file

@ -24,11 +24,136 @@ import { applyBranding } from './branding.js';
import { t } from './i18n.js';
import { isPlatformAdmin } from './utils.js';
import { renderWorkspaceSwitcher } from './components/workspace-switcher.js';
import { showToast } from './components/toast.js';
import { api } from './api.js';
const app = document.getElementById('app');
const sidebar = document.querySelector('.sidebar');
let currentView = null;
// ==================== Slice 2C: accept-invite plumbing ====================
//
// Flow shape (covers all six auth entry points - login, register, support,
// Google, Microsoft, first-user-setup - because they all funnel through
// onAuthSuccess() in login.js which calls window.location.reload()):
//
// 1. Hash route #/accept-invite/{id}:
// - unauthed: stash inviteId in localStorage, redirect to login
// - authed: call consumeAcceptInvite() directly (no stash)
// 2. App boot (every route() call once auth checks pass): if a valid
// non-stale stash is present, fire consumeAcceptInvite. After login
// reload lands here and picks it up automatically.
// 3. consumeAcceptInvite on success: stash toast text, switch workspace,
// reload. Reload re-fires route() which picks up the toast stash and
// shows it on dashboard. Reload is needed for the new JWT/socket/
// sidebar /me to pick up the new workspace context.
// 4. consumeAcceptInvite on error: showToast directly + clear stash.
// No reload (no state change to propagate).
const PENDING_INVITE_KEY = 'pending_invite';
const PENDING_INVITE_TOAST_KEY = 'pending_invite_toast';
// Mirrors the backend INVITE_EXPIRY_DAYS default (7). If an operator changes
// the backend default, this should be updated to match - tracked in handoff.
const INVITE_EXPIRY_DAYS_FRONTEND = 7;
// Non-reentrant guard: route() can fire multiple times (hashchange events).
// Once consume is in flight, additional calls no-op until reload completes.
let _acceptInFlight = false;
function stashPendingInvite(inviteId) {
localStorage.setItem(PENDING_INVITE_KEY, JSON.stringify({
inviteId,
stashedAt: Math.floor(Date.now() / 1000),
}));
}
function readPendingInvite() {
const raw = localStorage.getItem(PENDING_INVITE_KEY);
if (!raw) return null;
let parsed;
try { parsed = JSON.parse(raw); }
catch { localStorage.removeItem(PENDING_INVITE_KEY); return null; }
if (!parsed?.inviteId || !parsed?.stashedAt) {
localStorage.removeItem(PENDING_INVITE_KEY);
return null;
}
const ageSecs = Math.floor(Date.now() / 1000) - parsed.stashedAt;
if (ageSecs > INVITE_EXPIRY_DAYS_FRONTEND * 86400) {
localStorage.removeItem(PENDING_INVITE_KEY);
return null;
}
return parsed.inviteId;
}
function clearPendingInvite() {
localStorage.removeItem(PENDING_INVITE_KEY);
}
// Map backend error message text to a translated toast string. We match
// English text because api.js doesn't surface HTTP status codes today;
// refactor to err.status when that lands - tracked in handoff doc.
function mapAcceptError(err) {
const msg = err?.message || '';
if (/Invite not found/i.test(msg)) return t('accept.error.not_found');
if (/Invite has expired|Workspace no longer exists/i.test(msg)) return t('accept.error.expired');
if (/different email address/i.test(msg)) return t('accept.error.wrong_account');
return t('accept.error.generic');
}
async function consumeAcceptInvite(inviteId) {
if (_acceptInFlight) return;
_acceptInFlight = true;
try {
const result = await api.acceptInvite(inviteId);
// Switch to the joined workspace. New JWT carries the workspace context;
// reload picks it up for sidebar /me + socket rooms + data fetches. If
// the switch fails, log and reload anyway - the membership was created
// so the user can switch manually via the dropdown.
try {
const sw = await api.switchWorkspace(result.workspace_id);
if (sw?.token) localStorage.setItem('token', sw.token);
} catch (e) {
console.warn('switchWorkspace after accept failed (non-fatal):', e.message);
}
// Stash the toast text in a scoped key (not a generic pending-toast
// channel) so app boot below fires it after reload.
const toastKey = result.already_member ? 'accept.already_member' : 'accept.success';
localStorage.setItem(PENDING_INVITE_TOAST_KEY, JSON.stringify({
message: t(toastKey, { name: result.workspace_name }),
kind: 'success',
}));
clearPendingInvite();
// history.replaceState mutates the hash WITHOUT firing hashchange.
// Important: a plain `location.hash = '#/'` would fire hashchange
// synchronously, causing route() to fire a second time before the
// reload runs - that second route() call would consume the toast key
// and attach the toast to a DOM that's about to be destroyed by the
// reload. Using replaceState bypasses that race so the post-reload
// route() is the only one that picks up the toast.
history.replaceState(null, '', window.location.pathname + '#/');
window.location.reload();
} catch (err) {
showToast(mapAcceptError(err), 'error');
clearPendingInvite();
_acceptInFlight = false;
}
}
// Fires once per page load (single-shot key in localStorage). If the
// previous routeApp cycle stashed a toast across reload, show it now.
function consumePendingInviteToast() {
const raw = localStorage.getItem(PENDING_INVITE_TOAST_KEY);
if (!raw) return;
localStorage.removeItem(PENDING_INVITE_TOAST_KEY);
try {
const { message, kind } = JSON.parse(raw);
if (message) showToast(message, kind || 'info');
} catch {}
}
// Map nav-link data-view to its translation key.
const NAV_LABEL_KEYS = {
dashboard: 'nav.displays',
@ -110,6 +235,22 @@ function route() {
const hash = window.location.hash || '#/';
// Slice 2C - direct hits on #/accept-invite/{id}. Handle BEFORE the
// auth-redirect-to-login because an unauthed visit needs to stash the
// inviteId so it survives the redirect.
if (hash.startsWith('#/accept-invite/')) {
const inviteId = hash.split('#/accept-invite/')[1].split('/')[0];
if (inviteId) {
if (!isAuthenticated()) {
stashPendingInvite(inviteId);
window.location.hash = '#/login';
return;
}
consumeAcceptInvite(inviteId); // helper handles routing (reload to '#/')
return;
}
}
// Auth check - redirect to login if not authenticated
if (!isAuthenticated() && hash !== '#/login') {
window.location.hash = '#/login';
@ -122,6 +263,19 @@ function route() {
return;
}
// Slice 2C - past the auth gates. (a) Show any toast stashed across the
// accept-invite reload boundary. (b) If a stash exists (from an unauthed
// accept-invite visit + subsequent login/register), consume it now. The
// helper's in-flight guard prevents double-fire on subsequent hashchanges.
if (isAuthenticated()) {
consumePendingInviteToast();
const stashedInviteId = readPendingInvite();
if (stashedInviteId) {
consumeAcceptInvite(stashedInviteId);
return;
}
}
// Onboarding for new users
if (hash === '#/onboarding' && isAuthenticated()) {
sidebar.style.display = 'none';

View file

@ -1142,4 +1142,13 @@ export default {
'members.empty.invites': 'No pending invites.',
'members.load_error': 'Failed to load members: {error}',
'members.workspace_not_found': 'Workspace not found or no access.',
// Accept-invite flow (Slice 2C). Toasts that fire post-accept on the
// dashboard. Error variants share one helper in app.js's mapAcceptError().
'accept.success': "You've joined {name}",
'accept.already_member': "You're already a member of {name}",
'accept.error.not_found': 'Invite no longer valid',
'accept.error.expired': 'This invite has expired - ask the admin for a new one',
'accept.error.wrong_account': 'This invite is for a different email address. Sign out and sign in with the right account.',
'accept.error.generic': 'Failed to accept invite. Try again or contact your admin.',
};

View file

@ -265,8 +265,11 @@ router.post('/:id/invites', async (req, res) => {
// always carry the canonical origin. Falls back to request-derived for
// local dev and when PUBLIC_URL isn't set; with trust proxy on, req.protocol
// + req.get('host') reflect Cloudflare-forwarded X-Forwarded-Proto + Host.
// Path is /app#/accept-invite/<id> - the SPA lives at /app, so a bare
// /#/accept-invite/<id> would land on the marketing landing page in dev
// (and rely on the DISABLE_HOMEPAGE redirect in prod). /app is explicit.
const publicBase = process.env.PUBLIC_URL || `${req.protocol}://${req.get('host')}`;
const acceptUrl = `${publicBase}/#/accept-invite/${inviteId}`;
const acceptUrl = `${publicBase}/app#/accept-invite/${inviteId}`;
const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(ws.organization_id);
const { subject, text } = buildInviteEmail({
workspaceName: ws.name,