chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -17,7 +17,7 @@ const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
* table (so an S3↔filesystem swap carries it correctly), and writes
* the file id into `user_profiles.avatar_file_id`.
*
* Files are scoped to the user's CURRENT port the rep can't end up
* Files are scoped to the user's CURRENT port - the rep can't end up
* with an avatar that's only visible from one port. (Avatars render
* via the GET handler below, which presigns by id regardless of port.)
*/
@@ -98,7 +98,7 @@ export const POST = withAuth(async (req, ctx) => {
.where(eq(userProfiles.userId, ctx.userId));
if (priorAvatarId && priorAvatarId !== record.id) {
// Best-effort delete a stale-blob failure shouldn't fail the
// Best-effort delete - a stale-blob failure shouldn't fail the
// new-avatar response. deleteFile handles ref-check + blob
// delete + audit so a referenced file (somehow) is safe.
try {
@@ -111,7 +111,7 @@ export const POST = withAuth(async (req, ctx) => {
} catch (err) {
logger.warn(
{ err, priorAvatarId, userId: ctx.userId },
'avatar replace: failed to clean up prior avatar file orphan blob possible',
'avatar replace: failed to clean up prior avatar file - orphan blob possible',
);
}
}

View File

@@ -9,7 +9,7 @@ import { errorResponse, ValidationError } from '@/lib/errors';
import { env } from '@/lib/env';
/**
* Public confirmation endpoint clicked from the email sent to the
* Public confirmation endpoint - clicked from the email sent to the
* NEW address. Applies the email change atomically and redirects the
* user back to /settings with a success flag.
*

View File

@@ -45,7 +45,7 @@ export const PATCH = withAuth(async (req, ctx) => {
}
if (!REQUIRES_VERIFICATION) {
// Instant change dev only.
// Instant change - dev only.
const [updated] = await db
.update(user)
.set({ email, emailVerified: false, updatedAt: new Date() })
@@ -67,7 +67,7 @@ export const PATCH = withAuth(async (req, ctx) => {
return NextResponse.json({ data: { email: updated.email, instant: true } });
}
// Verification flow generate a single-use token, hash it, persist.
// Verification flow - generate a single-use token, hash it, persist.
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_MINUTES * 60 * 1000);
@@ -111,7 +111,7 @@ export const PATCH = withAuth(async (req, ctx) => {
const confirmBody = `
<p style="margin-bottom:16px;">Hi,</p>
<p style="margin-bottom:16px;">You (or someone using your account) requested to change the sign-in email on your ${appName} account from <strong>${safeOldEmail}</strong> to <strong>${safeNewEmail}</strong>.</p>
<p style="margin-bottom:16px;"><a href="${safeUrl(confirmUrl)}" style="color:#2563eb;font-weight:600;">Click here to confirm this change</a> the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
<p style="margin-bottom:16px;"><a href="${safeUrl(confirmUrl)}" style="color:#2563eb;font-weight:600;">Click here to confirm this change</a> - the link expires in ${VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
<p style="color:#64748b;">If you didn't request this, ignore this email.</p>
`;
const cancelBody = `

View File

@@ -10,7 +10,7 @@ import { errorResponse } from '@/lib/errors';
* one-time reset token and dispatches the email via the
* `sendResetPassword` callback configured in src/lib/auth/index.ts.
*
* The email always goes to the user's CURRENT account email no way
* The email always goes to the user's CURRENT account email - no way
* to redirect to a different inbox here, so the endpoint is safe even
* if a session is hijacked (the attacker can't move the reset email
* to themselves).

View File

@@ -13,14 +13,14 @@ import { errorResponse } from '@/lib/errors';
*
* M-NEW-1: this endpoint INTENTIONALLY skips `withAuth`'s port-context
* requirement. Callers hit /me/ports specifically to LEARN which ports
* they have access to they can't have selected one yet, so the
* they have access to - they can't have selected one yet, so the
* X-Port-Id header is by definition absent on the first call. Pre-fix
* this meant non-super-admins got a 400 "Port context required" and
* the client had to special-case the response shape.
*
* Auth is still enforced (session check); permissions logic skipped
* because the endpoint exposes only IDs+slugs+names of ports the user
* is already a member of same surface area as a `me` profile read.
* is already a member of - same surface area as a `me` profile read.
*/
export async function GET() {
try {

View File

@@ -18,12 +18,12 @@ const updateProfileSchema = z.object({
* Optional sign-in alias. `null` clears the existing value; a string
* must match the 230 lowercase shape pinned by USERNAME_REGEX (also
* enforced by `chk_user_profiles_username_shape` in migration 0054).
* Uniqueness is checked below before the UPDATE collisions surface
* Uniqueness is checked below before the UPDATE - collisions surface
* as a 409 with a friendly message.
*/
username: z.union([z.string().transform((s) => s.trim().toLowerCase()), z.null()]).optional(),
phone: z.string().nullable().optional(),
// Refuse `javascript:` / `data:` schemes z.string().url() lets them
// Refuse `javascript:` / `data:` schemes - z.string().url() lets them
// through and `<a href={avatarUrl}>` would otherwise be a stored-XSS
// vector if any future renderer treated the value as a link.
avatarUrl: z
@@ -32,7 +32,7 @@ const updateProfileSchema = z.object({
.refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL')
.nullable()
.optional(),
// Strict allow-list no `.passthrough()` here. The previous schema let
// Strict allow-list - no `.passthrough()` here. The previous schema let
// arbitrary client-supplied keys survive validation and persist into
// `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this.
// Add new keys here as the UI surfaces them rather than letting the
@@ -51,7 +51,7 @@ const updateProfileSchema = z.object({
// be set via hand-rolled SQL because the allow-list at line
// 154 silently stripped unknown keys.
defaultPortId: z.string().uuid().optional(),
// Per-table column visibility. Keyed by entity type entries
// Per-table column visibility. Keyed by entity type - entries
// with an empty `hiddenColumns` mean "all visible". The validator
// caps total entries / IDs so a malicious client can't bloat the
// 8 KB preferences blob; see merge step below for the byte cap.
@@ -65,7 +65,7 @@ const updateProfileSchema = z.object({
.strict(),
)
.optional(),
// Phase 4 per-user default reminder firing time-of-day. HH:MM in
// Phase 4 - per-user default reminder firing time-of-day. HH:MM in
// 24h local clock (validated below). Per-reminder dueAt overrides
// this; this is only the dialog's default when the rep doesn't
// pick an explicit time. Server clamps to '00:00''23:59'.
@@ -158,7 +158,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
}
}
if (body.preferences !== undefined) {
// Allow-list only retain keys defined in the strict schema. Pre-
// Allow-list - only retain keys defined in the strict schema. Pre-
// strict rows may carry extra keys from when the schema was
// .passthrough(); the merge prunes them so legacy bloat doesn't
// accumulate forever, and a future schema regression that tries
@@ -169,7 +169,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
'timezone',
'tablePreferences',
'defaultPortId',
// Phase 4 reminder default firing time.
// Phase 4 - reminder default firing time.
'digestTimeOfDay',
]);
const existing = (profile.preferences as Record<string, unknown>) ?? {};
@@ -178,7 +178,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
ALLOWED_PREF_KEYS.has(k),
),
);
// Hard cap on the merged JSONB defense in depth against any
// Hard cap on the merged JSONB - defense in depth against any
// future schema growth that might re-introduce free-form keys.
const serialized = JSON.stringify(merged);
if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
@@ -190,7 +190,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
// concurrency-auditor M-2: pre-check at line 132-139 is TOCTOU
// against `idx_user_profiles_username_unique`. Two concurrent claims
// on the same username will see "available" in their own pre-check
// and the loser's UPDATE fails with 23505 surface that as
// and the loser's UPDATE fails with 23505 - surface that as
// ConflictError rather than letting it bubble as a generic 500.
let updated;
try {