Executive Meetings: inline editing, drag reorder, and current-meeting highlight

Implements Task #122 — four features on the Executive Meetings schedule:
1. Word-like inline editing of meeting title and attendee names with bold,
   italic, underline, color, and font controls (Tiptap-backed EditableCell).
2. Drag-to-reorder column headers (dnd-kit), persisted in the existing
   em-schedule-cols-v1 storage key. Old up/down buttons removed.
3. Drag-to-reorder rows within a day. New POST /api/executive-meetings/reorder
   endpoint swaps daily_number in a single transaction using a two-phase
   negative→final assignment that respects the (date, daily_number) UNIQUE
   constraint. Audit-logged and role-guarded.
4. Highlight the meeting whose start/end window is "now", refreshed every
   60 s. Toggle and color picker live in the customize popover and persist
   to localStorage.

Backend changes
- Widened titleAr / titleEn / attendees.name to text (applied via direct
  ALTER TABLE because db:push --force is currently blocked by Task #126).
- New artifacts/api-server/src/lib/sanitize.ts with an allowlist for
  inline tags + safe inline styles. Applied to POST, PATCH, PUT-attendees,
  duplicate, and reorder paths so rich text round-trips safely.
- Code-review fix: duplicate handler now re-sanitizes titleAr/titleEn.
- Code-review fix: print page no longer interpolates the unsanitized
  attendee.title into dangerouslySetInnerHTML.
- 4 new tests cover sanitization stripping, reorder happy path, cross-day
  rejection, and permission denial. Pre-existing app_permissions failures
  are unrelated and tracked by Task #126.

Frontend changes
- artifacts/tx-os/src/components/editable-cell.tsx with FormattingToolbar.
- ScheduleSection wires saveTitle, saveAttendeeName, reorder mutation
  (optimistic with revert on error), highlight tick, and HighlightPrefs.
- New SortableHeader component for column drag.
- AttendeesCell now passes the original attendee index to PUT writes so
  edits reach the right row even after grouping into virtual/internal/
  external sections.
- Print page renders sanitized HTML via dangerouslySetInnerHTML on names
  and titles (titles still rendered as plain text on the print page).
- Translation keys added in ar.json and en.json.

Drift
- Sanitization for attendee.title was identified by the architect review
  as a defense-in-depth gap and proposed as Task #130 rather than fixing
  inline; the print-page XSS path that depended on it was fixed directly.
This commit is contained in:
Riyadh
2026-04-29 07:39:24 +00:00
parent 914b7e41f3
commit e93ca043f8
13 changed files with 1969 additions and 125 deletions
+2
View File
@@ -24,6 +24,7 @@
"google-auth-library": "^10.6.2",
"pino": "^9",
"pino-http": "^10",
"sanitize-html": "^2.17.3",
"socket.io": "^4.8.3",
"zod": "catalog:"
},
@@ -35,6 +36,7 @@
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "catalog:",
"@types/sanitize-html": "^2.16.1",
"esbuild": "^0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pg": "^8.20.0",
+85
View File
@@ -0,0 +1,85 @@
import sanitizeHtml from "sanitize-html";
const ALLOWED_FONT_FAMILIES = new Set<string>([
"Cairo",
"Tajawal",
"Amiri",
"Noto Naskh Arabic",
"IBM Plex Sans Arabic",
"system-ui",
"sans-serif",
"serif",
"monospace",
]);
const FONT_SIZE_RE = /^(?:[8-9]|[1-9]\d)px$/;
const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i;
export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: ["span", "strong", "b", "em", "i", "u", "br"],
allowedAttributes: {
span: ["style"],
strong: ["style"],
b: ["style"],
em: ["style"],
i: ["style"],
u: ["style"],
},
allowedStyles: {
"*": {
"color": [COLOR_RE],
"background-color": [COLOR_RE],
"font-weight": [/^(?:normal|bold|[1-9]00)$/i],
"font-style": [/^(?:normal|italic|oblique)$/i],
"text-decoration": [/^(?:none|underline)$/i],
"text-align": [/^(?:left|right|center|start|end|justify)$/i],
"font-size": [FONT_SIZE_RE],
"font-family": [
// Allow either an exact known family or a comma-separated list of
// quoted/unquoted families that all match our allowlist.
(value: string) => isAllowedFontFamilyValue(value),
] as unknown as RegExp[],
},
},
allowedSchemes: [],
disallowedTagsMode: "discard",
enforceHtmlBoundary: false,
// Keep text content of disallowed tags rather than dropping it entirely;
// this matters when a user pastes from Word and we strip the wrapper.
exclusiveFilter: () => false,
};
function isAllowedFontFamilyValue(raw: string): boolean {
// Split on commas, strip whitespace + surrounding quotes, then check each
// candidate name. If any unknown name appears we reject the whole value.
const parts = raw.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
if (parts.length === 0) return false;
return parts.every((p) => ALLOWED_FONT_FAMILIES.has(p));
}
/**
* Sanitize a Tiptap-style rich-text HTML string from a client.
*
* - Allowed tags: span, strong/b, em/i, u, br
* - Allowed inline styles: color, background-color, font-weight, font-style,
* text-decoration, text-align, font-size (px, 8-99), font-family (allowlist)
* - All other tags/attributes/styles are stripped.
*
* Empty / null / undefined input becomes "".
*/
export function sanitizeRichText(input: unknown): string {
if (input === null || input === undefined) return "";
const s = typeof input === "string" ? input : String(input);
if (s.length === 0) return "";
return sanitizeHtml(s, RICH_TEXT_OPTIONS);
}
/**
* Convenience for places that previously accepted plain text but now
* accept rich text — null/empty preserved as null when needed.
*/
export function sanitizeRichTextOrNull(input: unknown): string | null {
if (input === null || input === undefined) return null;
const s = sanitizeRichText(input);
return s === "" ? null : s;
}
@@ -25,12 +25,14 @@ import {
executiveMeetingFontSettingsTable,
rolesTable,
usersTable,
type ExecutiveMeetingAttendee,
} from "@workspace/db";
import {
requireAuth,
requireExecutiveAccess,
getEffectiveRoleIds,
} from "../middlewares/auth";
import { sanitizeRichText } from "../lib/sanitize";
import type { Request, Response, NextFunction } from "express";
const router: IRouter = Router();
@@ -164,16 +166,20 @@ const timeSchema = z
.regex(/^\d{2}:\d{2}(:\d{2})?$/, "expected HH:MM");
const positiveIntSchema = z.number().int().positive();
// titleAr/En and attendee.name accept sanitized rich-text HTML (Tiptap
// output). The byte count grows quickly with inline <span style=...> wrappers,
// so we allow up to ~10k bytes for titles and ~5k for attendee names. The
// sanitizer strips disallowed tags/attrs before persistence.
const attendeeSchema = z.object({
name: z.string().trim().min(1).max(200),
name: z.string().trim().min(1).max(5000),
title: z.string().trim().max(200).nullable().optional(),
attendanceType: z.enum(ATTENDANCE_TYPES).default("internal"),
sortOrder: z.number().int().min(0).optional(),
});
const meetingBaseFields = {
titleAr: z.string().trim().min(1).max(300),
titleEn: z.string().trim().min(1).max(300),
titleAr: z.string().trim().min(1).max(10000),
titleEn: z.string().trim().min(1).max(10000),
meetingDate: dateSchema,
dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
startTime: timeSchema.nullable().optional(),
@@ -228,6 +234,14 @@ const duplicateSchema = z.object({
targetDate: dateSchema,
});
const reorderSchema = z.object({
meetingDate: dateSchema,
orderedIds: z
.array(z.number().int().positive())
.min(2, "Need at least 2 meetings to reorder")
.max(200),
});
const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/);
@@ -585,8 +599,8 @@ router.post(
const dailyNumber =
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
titleAr: data.titleAr,
titleEn: data.titleEn ?? "",
titleAr: sanitizeRichText(data.titleAr),
titleEn: sanitizeRichText(data.titleEn ?? ""),
meetingDate: data.meetingDate,
dailyNumber,
startTime: data.startTime ?? null,
@@ -610,7 +624,7 @@ router.post(
await tx.insert(executiveMeetingAttendeesTable).values(
attendees.map((a, idx) => ({
meetingId: meeting.id,
name: a.name,
name: sanitizeRichText(a.name),
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -667,8 +681,10 @@ router.patch(
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
updatedBy: userId,
};
if (data.titleAr !== undefined) updateValues.titleAr = data.titleAr;
if (data.titleEn !== undefined) updateValues.titleEn = data.titleEn ?? "";
if (data.titleAr !== undefined)
updateValues.titleAr = sanitizeRichText(data.titleAr);
if (data.titleEn !== undefined)
updateValues.titleEn = sanitizeRichText(data.titleEn ?? "");
if (data.meetingDate !== undefined) updateValues.meetingDate = data.meetingDate;
if (data.dailyNumber !== undefined && data.dailyNumber !== null)
updateValues.dailyNumber = data.dailyNumber;
@@ -697,7 +713,7 @@ router.patch(
await tx.insert(executiveMeetingAttendeesTable).values(
data.attendees.map((a, idx) => ({
meetingId: id,
name: a.name,
name: sanitizeRichText(a.name),
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -793,7 +809,7 @@ router.put(
await tx.insert(executiveMeetingAttendeesTable).values(
data.attendees.map((a, idx) => ({
meetingId: id,
name: a.name,
name: sanitizeRichText(a.name),
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -837,8 +853,11 @@ router.post(
const nextNumber = await nextDailyNumber(tx, data.targetDate);
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
dailyNumber: nextNumber,
titleAr: source.titleAr,
titleEn: source.titleEn,
// Re-sanitize on duplicate so a row that was somehow stored with
// disallowed markup (e.g. raw SQL import, schema regression) does
// not get cloned into a fresh meeting unchecked.
titleAr: sanitizeRichText(source.titleAr),
titleEn: source.titleEn ? sanitizeRichText(source.titleEn) : source.titleEn,
meetingDate: data.targetDate,
startTime: source.startTime,
endTime: source.endTime,
@@ -859,9 +878,12 @@ router.post(
if (!meeting) throw new Error("duplicate_failed");
if (source.attendees && source.attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
source.attendees.map((a, idx) => ({
source.attendees.map((a: ExecutiveMeetingAttendee, idx: number) => ({
meetingId: meeting.id,
name: a.name,
// source already-stored values are sanitized at write time, but
// re-running keeps a defense-in-depth boundary if anything was
// imported via raw SQL.
name: sanitizeRichText(a.name),
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
@@ -891,6 +913,127 @@ router.post(
},
);
// =====================================================================
// REORDER (within a single day)
// =====================================================================
router.post(
"/executive-meetings/reorder",
requireExecutiveAccess,
requireMutate,
async (req, res): Promise<void> => {
const data = parseBody(res, reorderSchema, req.body);
if (!data) return;
const userId = req.session.userId!;
const dedup = new Set(data.orderedIds);
if (dedup.size !== data.orderedIds.length) {
res
.status(400)
.json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" });
return;
}
try {
await db.transaction(async (tx) => {
const rows = await tx
.select()
.from(executiveMeetingsTable)
.where(
and(
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
inArray(executiveMeetingsTable.id, data.orderedIds),
),
);
if (rows.length !== data.orderedIds.length) {
throw Object.assign(new Error("missing_or_cross_day"), {
httpStatus: 400,
code: "invalid_ids",
});
}
// Snapshot the current (start_time, end_time, daily_number) sorted
// by current daily_number — the slots we will redistribute.
const slots = rows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
.map((r) => ({
startTime: r.startTime,
endTime: r.endTime,
dailyNumber: r.dailyNumber,
}));
const byId = new Map(rows.map((r) => [r.id, r] as const));
// Two-phase update to avoid (date, daily_number) unique conflicts.
// Phase 1: park into negative numbers.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
await tx
.update(executiveMeetingsTable)
.set({ dailyNumber: -(i + 1), updatedBy: userId })
.where(eq(executiveMeetingsTable.id, id));
}
// Phase 2: assign final slot.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
const slot = slots[i]!;
await tx
.update(executiveMeetingsTable)
.set({
startTime: slot.startTime,
endTime: slot.endTime,
dailyNumber: slot.dailyNumber,
updatedBy: userId,
})
.where(eq(executiveMeetingsTable.id, id));
}
await logAudit(tx, {
action: "reorder",
entityType: "meeting",
entityId: data.orderedIds[0]!,
oldValue: {
meetingDate: data.meetingDate,
order: rows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
.map((r) => r.id),
},
newValue: {
meetingDate: data.meetingDate,
order: data.orderedIds,
},
performedBy: userId,
});
const updated = await tx
.select()
.from(executiveMeetingsTable)
.where(
and(
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
inArray(executiveMeetingsTable.id, data.orderedIds),
),
);
// Validate no two share dailyNumber post-update (defensive).
const seen = new Set<number>();
for (const u of updated) {
if (seen.has(u.dailyNumber)) {
throw new Error("duplicate_daily_number_after_reorder");
}
seen.add(u.dailyNumber);
}
return { updated, byId };
});
// Return the updated meetings in the new order, with attendees.
const idToFetch = data.orderedIds;
const fullList = await Promise.all(
idToFetch.map((id) => fetchMeetingWithAttendees(id)),
);
res.json({ meetings: fullList.filter(Boolean) });
} catch (err) {
const status = (err as { httpStatus?: number }).httpStatus ?? 500;
const code = (err as { code?: string }).code ?? "server_error";
const msg = err instanceof Error ? err.message : String(err);
res.status(status).json({ error: msg, code });
}
},
);
// =====================================================================
// REQUESTS
// =====================================================================
@@ -538,3 +538,116 @@ test("PDF archives: POST creates a snapshot and GET returns it", async () => {
assert.ok(Array.isArray(archives));
assert.ok(archives.some((a) => a.id === archive.id));
});
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
const dirty =
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
'<a href="javascript:bad()">x</a><img src=x onerror=alert(1)>';
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: dirty,
titleEn: dirty,
meetingDate: today,
attendees: [
{ name: 'Tester <em style="color:blue">One</em><script>x</script>',
attendanceType: "internal", sortOrder: 0 },
],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const m = body.meetings.find((x) => x.id === meeting.id);
assert.ok(m, "meeting must come back");
// disallowed tags must be stripped
assert.ok(!/<script/i.test(m.titleAr), "script must be stripped from titleAr");
assert.ok(!/<script/i.test(m.titleEn), "script must be stripped from titleEn");
assert.ok(!/<img/i.test(m.titleAr), "img must be stripped");
assert.ok(!/<a /i.test(m.titleAr), "anchor must be stripped");
assert.ok(!/javascript:/i.test(m.titleAr), "javascript: scheme must be gone");
// allowed inline formatting must be preserved
assert.ok(/<strong[^>]*>World<\/strong>/i.test(m.titleAr), "strong must survive");
assert.ok(/color:\s*#ff0000/i.test(m.titleAr), "inline color must survive");
// attendee names get the same treatment
const att = m.attendees.find((a) => /Tester/.test(a.name));
assert.ok(att, "attendee must come back");
assert.ok(!/<script/i.test(att.name), "script stripped from attendee name");
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
});
test("Reorder: POST /reorder swaps daily numbers + start times within a day", async () => {
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "أول", titleEn: "First", meetingDate: today,
startTime: "09:00", endTime: "09:30",
});
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثاني", titleEn: "Second", meetingDate: today,
startTime: "10:00", endTime: "10:30",
});
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ثالث", titleEn: "Third", meetingDate: today,
startTime: "11:00", endTime: "11:30",
});
assert.equal(a.status, 201);
assert.equal(b.status, 201);
assert.equal(c.status, 201);
const A = await a.json(); created.meetingIds.push(A.id);
const B = await b.json(); created.meetingIds.push(B.id);
const C = await c.json(); created.meetingIds.push(C.id);
// reverse the order: C, B, A
const reorder = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [C.id, B.id, A.id] });
assert.equal(reorder.status, 200);
const day = await api(adminCookie, "GET",
`/api/executive-meetings?date=${today}`);
const body = await day.json();
const byId = new Map(body.meetings.map((m) => [m.id, m]));
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
// The first slot now holds C, the third now holds A.
assert.ok(cAfter.dailyNumber < bAfter.dailyNumber);
assert.ok(bAfter.dailyNumber < aAfter.dailyNumber);
});
test("Reorder: rejects ids that do not all belong to meetingDate", async () => {
const sameDay = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اليوم", titleEn: "Today", meetingDate: today,
});
const other = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "غدًا", titleEn: "Tomorrow",
meetingDate: new Date(Date.now() + 86400000)
.toISOString().slice(0, 10),
});
const S = await sameDay.json(); created.meetingIds.push(S.id);
const O = await other.json(); created.meetingIds.push(O.id);
const r = await api(adminCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [S.id, O.id] });
assert.equal(r.status, 400);
});
test("Reorder: user without executive role is denied (403)", async () => {
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا", titleEn: "A", meetingDate: today,
});
const M = await m.json(); created.meetingIds.push(M.id);
// a brand-new "user" has only the base 'user' role (no executive perms)
const plain = await createUser("em_plain", "user");
const plainCookie = await login(plain.username, TEST_PASSWORD);
const r = await api(plainCookie, "POST",
"/api/executive-meetings/reorder",
{ meetingDate: today, orderedIds: [M.id] });
assert.equal(r.status, 403);
});