Spreadsheet-UX op een echte relationele engine

main

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

3.6 kB Ruw bestand
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Member } from '@wellformed/shared';
import { badRequest, conflict, notFound, unauthorized } from '../errors.js';
import {
  countActiveAdmins,
  listMembers,
  removeMembership,
  setMembership,
} from '../control/workspaces.js';
import { recordAudit } from '../engine/audit.js';
import { updateMemberSchema } from './schemas.js';

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

/**
 * Who is in this workspace, and what they may do here.
 *
 * Note what is *not* here. There is no create-a-person route any more: an
 * account is global, so a workspace admin cannot mint one, only invite one.
 * And there is no password reset or disable: those belong to the account, not
 * to any single workspace, and letting one customer's admin disable an account
 * shared with another customer would be a cross-tenant hole.
 */
export async function memberRoutes(app: FastifyInstance): Promise<void> {
  app.get('/members', async (request): Promise<{ members: Member[] }> => {
    // Reading the workspace is not reading the people in it. A visitor to the
    // published demo holds `viewer` without holding an account, and this list
    // is names, email addresses and when each of them was last here.
    if (!request.ws.require('viewer')) {
      throw unauthorized('Sign in to see who is in this workspace.');
    }
    return { members: listMembers(app.control, request.ws.workspace.id) };
  });

  app.patch('/members/:id', async (request) => {
    const { workspace } = request.ws;
    const actor = request.ws.require('admin');
    const { id } = idParams.parse(request.params);
    const body = updateMemberSchema.parse(request.body);

    const target = listMembers(app.control, workspace.id).find((m) => m.id === id);
    if (!target) throw notFound('That person is not in this workspace.');
    if (!body.role || body.role === target.role) return { member: target };

    // An admin must not be able to leave the workspace with no way back in.
    if (target.role === 'admin' && countActiveAdmins(app.control, workspace.id) <= 1) {
      throw conflict('This is the last active admin. Promote someone else first.');
    }

    setMembership(app.control, workspace.id, id, body.role);
    recordAudit(request.ws.db, request.ws.actor(), {
      kind: 'auth',
      old: target.email,
      nw: `${target.email}: role ${target.role} → ${body.role}`,
    });

    return {
      member: listMembers(app.control, workspace.id).find((m) => m.id === id),
      /** A caller who just demoted themselves needs to know before their next request 403s. */
      you: id === actor.id ? body.role : undefined,
    };
  });

  app.delete('/members/:id', async (request) => {
    const { workspace } = request.ws;
    const actor = request.ws.require('admin');
    const { id } = idParams.parse(request.params);

    if (id === actor.id) {
      throw badRequest('Use "leave workspace" to remove yourself.');
    }

    const target = listMembers(app.control, workspace.id).find((m) => m.id === id);
    if (!target) throw notFound('That person is not in this workspace.');
    if (target.role === 'admin' && countActiveAdmins(app.control, workspace.id) <= 1) {
      throw conflict('This is the last active admin.');
    }

    // The membership goes; the account does not. They may well be the sole
    // admin of a workspace of their own.
    removeMembership(app.control, workspace.id, id);
    recordAudit(request.ws.db, request.ws.actor(), {
      kind: 'auth',
      old: target.email,
      nw: `removed ${target.email} from the workspace`,
    });

    return { ok: true };
  });
}