Source: routes/workspaces.js

/**
 * @module routes/workspaces
 * @description Workspace and member management routes (ACL-001, ACL-002).
 *
 * ### Endpoints
 * | Method | Path                                    | Description                   | Min Role |
 * |--------|-----------------------------------------|-------------------------------|----------|
 * | GET    | `/api/workspaces`                       | List user's workspaces        | viewer   |
 * | POST   | `/api/workspaces/switch`                | Switch active workspace       | viewer   |
 * | GET    | `/api/workspaces/current`               | Get current workspace info    | viewer   |
 * | PATCH  | `/api/workspaces/current`               | Update workspace name/slug    | admin    |
 * | GET    | `/api/workspaces/current/members`       | List workspace members        | viewer   |
 * | POST   | `/api/workspaces/current/members`       | Invite a member               | admin    |
 * | PATCH  | `/api/workspaces/current/members/:userId` | Update member role          | admin    |
 * | DELETE | `/api/workspaces/current/members/:userId` | Remove a member             | admin    |
 */

import { Router } from "express";
import * as workspaceRepo from "../database/repositories/workspaceRepo.js";
import * as userRepo from "../database/repositories/userRepo.js";
import * as webauthnRepo from "../database/repositories/webauthnRepo.js";
import { requireRole, VALID_ROLES } from "../middleware/requireRole.js";
import { signJwt, getJwtSecret, revokedTokens } from "../middleware/authenticate.js";
import { buildJwtPayload, buildUserResponse } from "../utils/authWorkspace.js";
import { logActivity } from "../utils/activityLogger.js";
import { evaluateMfaEnforcement } from "../utils/mfaEnforcement.js";
import { setAuthCookie, JWT_TTL_SEC } from "./auth.js";

const router = Router();

// ─── Current workspace info ───────────────────────────────────────────────────

/**
 * Get the current workspace details.
 * @route GET /api/workspaces/current
 */
router.get("/current", (req, res) => {
  const ws = workspaceRepo.getById(req.workspaceId);
  if (!ws) return res.status(404).json({ error: "Workspace not found." });
  return res.json(ws);
});

/**
 * Update the current workspace (name, slug, MFA enforcement policy).
 *
 * SEC-004: `mfaRequired` / `mfaGracePeriodDays` are admin-managed enforcement
 * controls. Flipping `mfaRequired` from 0 → 1 stamps `mfaPolicyUpdatedAt`
 * with the current time so the grace clock starts at the policy change —
 * existing members get the full grace window before being locked out.
 *
 * @route PATCH /api/workspaces/current
 */
router.patch("/current", requireRole("admin"), (req, res) => {
  const { name, slug, mfaRequired, mfaGracePeriodDays } = req.body;
  const updates = {};
  if (name && typeof name === "string") updates.name = name.trim().slice(0, 100);
  if (slug && typeof slug === "string") {
    const cleanSlug = slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
    if (!cleanSlug) return res.status(400).json({ error: "Invalid slug." });
    // Check uniqueness
    const existing = workspaceRepo.getBySlug(cleanSlug);
    if (existing && existing.id !== req.workspaceId) {
      return res.status(409).json({ error: "This slug is already taken." });
    }
    updates.slug = cleanSlug;
  }

  // SEC-004: MFA enforcement policy. Accept booleans or 0/1 for `mfaRequired`
  // for ergonomic frontend payloads; coerce to the integer column shape.
  let mfaPolicyChanged = false;
  if (mfaRequired !== undefined) {
    const next = (mfaRequired === true || mfaRequired === 1 || mfaRequired === "1") ? 1 : 0;
    const current = workspaceRepo.getById(req.workspaceId);
    if (!current) return res.status(404).json({ error: "Workspace not found." });
    if (next !== (current.mfaRequired || 0)) {
      updates.mfaRequired = next;
      // Stamp policy-changed timestamp so the grace clock starts here.
      updates.mfaPolicyUpdatedAt = new Date().toISOString();
      mfaPolicyChanged = true;
    }
  }
  if (mfaGracePeriodDays !== undefined) {
    const n = Number.parseInt(mfaGracePeriodDays, 10);
    if (!Number.isFinite(n) || n < 0 || n > 90) {
      return res.status(400).json({ error: "mfaGracePeriodDays must be between 0 and 90." });
    }
    updates.mfaGracePeriodDays = n;
  }

  if (Object.keys(updates).length === 0) {
    return res.status(400).json({ error: "No valid fields to update." });
  }
  workspaceRepo.update(req.workspaceId, updates);
  const ws = workspaceRepo.getById(req.workspaceId);

  if (mfaPolicyChanged) {
    logActivity({
      type: "workspace.mfa_policy_changed",
      req,
      detail: `MFA enforcement ${ws.mfaRequired === 1 ? "enabled" : "disabled"} for workspace.`,
      userId: req.authUser.sub, userName: req.authUser.name || req.authUser.email,
      workspaceId: req.workspaceId,
      meta: { mfaRequired: ws.mfaRequired, mfaGracePeriodDays: ws.mfaGracePeriodDays },
    });
  }

  return res.json(ws);
});

// ─── Workspace listing & switching ────────────────────────────────────────────

/**
 * List all workspaces the current user belongs to.
 * @route GET /api/workspaces
 */
router.get("/", (req, res) => {
  const userId = req.authUser.sub;
  const workspaces = workspaceRepo.getByUserId(userId);
  return res.json(workspaces.map(ws => ({
    id: ws.id, name: ws.name, slug: ws.slug, role: ws.role,
    isOwner: ws.ownerId === userId, createdAt: ws.createdAt,
  })));
});

/**
 * Switch the active workspace. Issues a new JWT with the target workspaceId
 * hint and returns updated user info. The user must be a member of the
 * target workspace.
 *
 * @route POST /api/workspaces/switch
 * @param {Object} req.body
 * @param {string} req.body.workspaceId — The workspace to switch to.
 */
router.post("/switch", (req, res) => {
  const { workspaceId: targetId } = req.body;
  if (!targetId || typeof targetId !== "string") {
    return res.status(400).json({ error: "workspaceId is required." });
  }

  const userId = req.authUser.sub;
  const membership = workspaceRepo.getMembership(targetId, userId);
  if (!membership) {
    return res.status(403).json({ error: "You are not a member of that workspace." });
  }

  const user = userRepo.getById(userId);
  if (!user) return res.status(401).json({ error: "User not found." });

  // SEC-004 §11: re-check MFA enforcement on every workspace switch.
  //
  // Strictest-wins semantics: `evaluateMfaEnforcement(user)` inspects EVERY
  // workspace the user belongs to (not just `targetId`) and returns the
  // strictest outcome. This intentionally prevents a user from escaping
  // enforcement by switching from a strict-MFA workspace into a no-MFA one
  // — once any of their workspaces requires MFA past grace, they must
  // enroll before any workspace switch succeeds. See the contract in
  // `backend/src/utils/mfaEnforcement.js`.
  //
  // Same pattern as the /refresh enforcement check in routes/auth.js.
  const enforcement = evaluateMfaEnforcement(user);
  if (enforcement.state === "block") {
    logActivity({
      type: "auth.mfa.enrollment_required",
      req,
      detail: "Workspace switch blocked: target workspace requires MFA.",
      userId: user.id, userName: user.name || user.email,
      workspaceId: enforcement.workspaceId,
      meta: { method: "workspace_switch" },
    });
    return res.status(403).json({
      error: "Your workspace requires multi-factor authentication. Enroll before switching.",
      code: "MFA_ENROLLMENT_REQUIRED",
      workspaceId: enforcement.workspaceId,
      workspaceName: enforcement.workspaceName,
    });
  }

  // Revoke the old token so it cannot be replayed (matches /refresh behaviour)
  const { jti: oldJti, exp: oldExp } = req.authUser;
  if (oldJti) revokedTokens.set(oldJti, oldExp);

  // Issue a new JWT with the target workspace as the hint.
  // SEC-004 §5c: forward the existing `amr` claim so a workspace switch by an
  // MFA-asserted user does not silently downgrade their session to password-
  // only. Same rationale as the `/refresh` forward at routes/auth.js — both
  // revoke + reissue paths must preserve the authentication strength.
  const payload = buildJwtPayload(user, targetId, { amr: req.authUser.amr });
  const token = signJwt(payload, getJwtSecret());
  const exp = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;

  setAuthCookie(res, token, exp);

  return res.json({ user: buildUserResponse(user, targetId) });
});

// ─── Member management ────────────────────────────────────────────────────────

/**
 * List all members of the current workspace.
 * @route GET /api/workspaces/current/members
 */
router.get("/current/members", (req, res) => {
  const members = workspaceRepo.getMembers(req.workspaceId);
  return res.json(members);
});

/**
 * Report MFA enrollment compliance for the current workspace (SEC-004).
 * Used by the admin Settings panel to show "N of M members not enrolled"
 * before flipping the enforcement policy.
 *
 * @route GET /api/workspaces/current/mfa-compliance
 * @returns {200} `{ totalMembers, enrolled, notEnrolled, members: [{userId,name,email,mfaEnabled}] }`
 */
router.get("/current/mfa-compliance", requireRole("admin"), (req, res) => {
  const members = workspaceRepo.getMembers(req.workspaceId);
  const detailed = members.map((m) => {
    const u = userRepo.getById(m.userId);
    // SEC-004: a registered passkey is just as strong a second factor as TOTP
    // (see `evaluateMfaEnforcement` at backend/src/utils/mfaEnforcement.js).
    // Count passkey-only users as enrolled so the admin preview matches what
    // the enforcement engine will actually allow — otherwise OAuth-only users
    // who registered a passkey but no TOTP are misreported as "not enrolled".
    const passkeyCount = webauthnRepo.countByUser(m.userId);
    const totp = u?.mfaEnabled === 1;
    const webauthn = passkeyCount > 0;
    return {
      userId: m.userId,
      name: m.name,
      email: m.email,
      role: m.role,
      mfaEnabled: totp || webauthn,
      totp,
      webauthn,
    };
  });
  const enrolled = detailed.filter((d) => d.mfaEnabled).length;
  return res.json({
    totalMembers: detailed.length,
    enrolled,
    notEnrolled: detailed.length - enrolled,
    members: detailed,
  });
});

/**
 * Invite a user to the current workspace by email.
 * @route POST /api/workspaces/current/members
 */
router.post("/current/members", requireRole("admin"), (req, res) => {
  const { email, role } = req.body;
  if (!email || typeof email !== "string") {
    return res.status(400).json({ error: "Email is required." });
  }
  const memberRole = role || "viewer";
  if (!VALID_ROLES.has(memberRole)) {
    return res.status(400).json({ error: `Invalid role. Must be one of: ${[...VALID_ROLES].join(", ")}` });
  }

  const user = userRepo.getByEmail(email.trim().toLowerCase());
  if (!user) {
    return res.status(404).json({ error: "No user found with that email. They must register first." });
  }

  // Check if already a member
  const existing = workspaceRepo.getMembership(req.workspaceId, user.id);
  if (existing) {
    return res.status(409).json({ error: "User is already a member of this workspace." });
  }

  const membership = workspaceRepo.addMember(req.workspaceId, user.id, memberRole);
  return res.status(201).json({
    ...membership,
    name: user.name,
    email: user.email,
    avatar: user.avatar || null,
  });
});

/**
 * Update a member's role.
 * @route PATCH /api/workspaces/current/members/:userId
 */
router.patch("/current/members/:userId", requireRole("admin"), (req, res) => {
  const { userId } = req.params;
  const { role } = req.body;

  if (!role || !VALID_ROLES.has(role)) {
    return res.status(400).json({ error: `Invalid role. Must be one of: ${[...VALID_ROLES].join(", ")}` });
  }

  // Prevent demoting the last admin
  if (role !== "admin") {
    const members = workspaceRepo.getMembers(req.workspaceId);
    const admins = members.filter(m => m.role === "admin");
    if (admins.length === 1 && admins[0].userId === userId) {
      return res.status(400).json({ error: "Cannot remove the last admin. Promote another member first." });
    }
  }

  // Capture the previous role for the audit-log meta before mutating, so
  // a SOC-2 reviewer can answer "what changed?" without a separate query.
  const beforeMembers = workspaceRepo.getMembers(req.workspaceId);
  const previousRole = beforeMembers.find((m) => m.userId === userId)?.role || null;

  const updated = workspaceRepo.updateMemberRole(req.workspaceId, userId, role);
  if (!updated) {
    return res.status(404).json({ error: "Member not found in this workspace." });
  }

  // SEC-007: emit `auth.role.change` so the workspace audit log captures
  // every role mutation with actor + target + before/after + IP/UA.
  const target = userRepo.getById(userId);
  logActivity({
    type: "auth.role.change",
    req,
    userId,
    userName: target?.name || target?.email || null,
    workspaceId: req.workspaceId,
    meta: {
      from: previousRole,
      to: role,
      changedBy: req.authUser.sub,
      changedByName: req.authUser.name || req.authUser.email || null,
    },
  });

  return res.json({ userId, role, updated: true });
});

/**
 * Remove a member from the workspace.
 * @route DELETE /api/workspaces/current/members/:userId
 */
router.delete("/current/members/:userId", requireRole("admin"), (req, res) => {
  const { userId } = req.params;

  // Prevent removing yourself if you're the last admin
  const members = workspaceRepo.getMembers(req.workspaceId);
  const admins = members.filter(m => m.role === "admin");
  if (admins.length === 1 && admins[0].userId === userId) {
    return res.status(400).json({ error: "Cannot remove the last admin. Transfer ownership first." });
  }

  const removed = workspaceRepo.removeMember(req.workspaceId, userId);
  if (!removed) {
    return res.status(404).json({ error: "Member not found in this workspace." });
  }
  return res.json({ userId, removed: true });
});

export default router;