Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

6.9 kB Ruw bestand
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import type { InviteInfo, PendingInvite } from '@wellformed/shared';
import { badRequest, conflict, forbidden, notFound } from '../errors.js';
import { config } from '../config.js';
import {
  createAccount,
  getAccountByEmail,
  normaliseEmail,
} from '../control/accounts.js';
import { transactControl } from '../control/db.js';
import { createSession, SESSION_COOKIE } from '../control/sessions.js';
import {
  claimInvite,
  createInvite,
  getLiveInviteByToken,
  listPendingInvites,
  revokeInvite,
} from '../control/invites.js';
import {
  getMembershipRole,
  getWorkspaceById,
  listMembers,
  setMembership,
} from '../control/workspaces.js';
import { checkPasswordStrength, hashPassword } from '../auth/password.js';
import { recordAudit } from '../engine/audit.js';
import { acceptInviteSchema, createInviteSchema } from './schemas.js';

const idParams = z.object({ id: z.string().min(1).max(64) });
const tokenParams = z.object({ token: z.string().min(20).max(200) });

/** Managing invites for one workspace. Registered inside the tenant scope. */
export async function inviteRoutes(app: FastifyInstance): Promise<void> {
  app.get('/invites', async (request): Promise<{ invites: PendingInvite[] }> => {
    request.ws.require('admin');
    return { invites: listPendingInvites(app.control, request.ws.workspace.id) };
  });

  app.post('/invites', async (request, reply) => {
    const { workspace } = request.ws;
    const actor = request.ws.require('admin');
    const body = createInviteSchema.parse(request.body);
    const email = normaliseEmail(body.email);

    const existing = getAccountByEmail(app.control, email);
    if (existing && getMembershipRole(app.control, workspace.id, existing.id)) {
      throw conflict(`${email} is already in this workspace.`);
    }

    const { record, token } = createInvite(app.control, {
      workspaceId: workspace.id,
      email,
      role: body.role,
      invitedBy: actor.id,
    });

    recordAudit(request.ws.db, request.ws.actor(), {
      kind: 'auth',
      nw: `invited ${email} as ${body.role}`,
    });

    reply.code(201);
    return {
      invite: {
        id: record.id,
        email: record.email,
        role: record.role,
        invitedBy: actor.name,
        createdAt: record.createdAt,
        expiresAt: record.expiresAt,
        acceptPath: `/invite/${token}`,
      } satisfies PendingInvite,
    };
  });

  app.delete('/invites/:id', async (request) => {
    request.ws.require('admin');
    const { id } = idParams.parse(request.params);
    revokeInvite(app.control, id);
    return { ok: true };
  });
}

/**
 * Accepting an invite.
 *
 * Outside the tenant scope on purpose: the whole point is that the person
 * following the link is not a member yet, so the membership check that guards
 * `/api/w/:slug` would reject them. The token is the authorisation here, and
 * it is checked on both routes.
 */
export async function inviteAcceptRoutes(app: FastifyInstance): Promise<void> {
  app.get('/api/invites/:token', async (request): Promise<{ invite: InviteInfo }> => {
    const { token } = tokenParams.parse(request.params);
    const invite = getLiveInviteByToken(app.control, token);
    if (!invite) throw notFound('This invitation has expired or has already been used.');

    const workspace = getWorkspaceById(app.control, invite.workspaceId);
    if (!workspace) throw notFound('That workspace no longer exists.');

    return {
      invite: {
        workspaceName: workspace.name,
        workspaceSlug: workspace.slug,
        email: invite.email,
        role: invite.role,
        expiresAt: invite.expiresAt,
        needsAccount: !getAccountByEmail(app.control, invite.email),
      },
    };
  });

  app.post('/api/invites/:token/accept', async (request, reply) => {
    const { token } = tokenParams.parse(request.params);
    const body = acceptInviteSchema.parse(request.body);

    const invite = getLiveInviteByToken(app.control, token);
    if (!invite) throw notFound('This invitation has expired or has already been used.');

    const workspace = getWorkspaceById(app.control, invite.workspaceId);
    if (!workspace) throw notFound('That workspace no longer exists.');

    const existing = getAccountByEmail(app.control, invite.email);

    // The invite names an address, and only that address can accept it. A
    // forwarded link must not be able to attach a stranger's account instead.
    if (request.account && request.account.email !== invite.email) {
      throw forbidden(
        `This invitation is for ${invite.email}. Sign out first, or ask for one addressed to ${request.account.email}.`,
      );
    }

    let accountId: string;
    let issueSession = false;

    if (existing) {
      if (!request.account) {
        // They have an account but are not signed in. Sending them to the
        // login screen is better than accepting a password on this route,
        // which would be an unthrottled second way to check credentials.
        throw conflict(`${invite.email} already has an account. Sign in, then open this link again.`);
      }
      accountId = existing.id;
    } else {
      if (!body.name || !body.password) {
        throw badRequest('Choose a name and a password to accept this invitation.');
      }
      const problem = checkPasswordStrength(body.password);
      if (problem) throw badRequest(problem.message);
      const hash = await hashPassword(body.password);
      accountId = createAccount(app.control, {
        email: invite.email,
        name: body.name,
        passwordHash: hash,
      }).id;
      issueSession = true;
    }

    const claimed = transactControl(app.control, () => {
      if (!claimInvite(app.control, invite.id, accountId)) return false;
      setMembership(app.control, workspace.id, accountId, invite.role);
      return true;
    });

    if (!claimed) throw conflict('This invitation has already been used.');

    if (issueSession) {
      const session = createSession(
        app.control,
        accountId,
        config.sessionTtlMs,
        request.headers['user-agent'],
      );
      setSessionCookie(reply, session.token, session.expiresAt);
    }

    // Written to the workspace's own audit trail, where its admins will look.
    const joined = listMembers(app.control, workspace.id).find((m) => m.id === accountId);
    const workspaceDb = app.registry.acquire(workspace);
    try {
      recordAudit(workspaceDb, { id: accountId, name: joined?.name ?? invite.email }, {
        kind: 'auth',
        nw: `${invite.email} joined as ${invite.role}`,
      });
    } finally {
      app.registry.release(workspace.id);
    }

    return { workspace: { slug: workspace.slug, name: workspace.name, role: invite.role } };
  });
}

function setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date): void {
  reply.setCookie(SESSION_COOKIE, token, {
    httpOnly: true,
    sameSite: 'lax',
    secure: config.isProd,
    path: '/',
    expires: expiresAt,
  });
}