mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
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:
parent
8db171d979
commit
399af54839
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue