2026-04-08 15:47:11 -04:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
import { type ColumnDef } from '@tanstack/react-table';
|
2026-05-12 16:14:12 +02:00
|
|
|
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff, Power, PowerOff } from 'lucide-react';
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
import { DataTable } from '@/components/shared/data-table';
|
|
|
|
|
import { PageHeader } from '@/components/shared/page-header';
|
|
|
|
|
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
fix(audit-v3): platform-wide deferred-list cleanup (rounds 1-4)
Working through the audit-v2 deferred backlog. Each round was tested
(typecheck + 1168/1168 vitest) before moving on.
Round 1 — DB performance + AI cost visibility:
- Add missing FK indexes Postgres doesn't auto-create on
berth_reservations.{interest_id, contract_file_id},
documents.{file_id, signed_file_id}, document_events.signer_id,
document_templates.source_file_id, form_submissions.{form_template_id,
client_id}, document_sends.{brochure_id, brochure_version_id,
sent_by_user_id}. Without these, RESTRICT-checks on parent delete +
reverse-lookups walk the child tables fully. Migration 0037.
- AI worker now writes one ai_usage_ledger row per OpenAI call so admins
can audit spend per port/user/feature and future per-port budgets have
history to read from. Failure to write is logged-not-thrown so the
user-facing email draft is unaffected.
Round 2 — Boot-time + transport hardening:
- S3 backend verifies the bucket exists at startup (or auto-creates
when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now
surfaces with a clear boot error instead of a vague Minio error
inside the first user-facing request.
- Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx
+ network errors, fail-fast on 4xx. Stops one transient flake from
leaving a document with a partial field set.
- FilesystemBackend logs a structured warn-once at boot when the dev
HMAC fallback is in effect, so two processes started with different
BETTER_AUTH_SECRET values are observable (random 401s on file
downloads otherwise).
- Logger redact paths extended to cover *.headers.{authorization,
cookie}, *.config.headers.authorization, encrypted-credential blobs
(secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso
X-Documenso-Secret header, and 2-level nested forms.
Round 3 — UI feedback + permission gates:
- Storage admin migrate dialog: success toast with row count + error
toast on both dryRun and migrate mutations.
- Invoice detail Send + Record-payment buttons wrapped in
PermissionGate (invoices.send / invoices.record_payment); both
mutations now toast on success/error.
- Admin user list Edit button wrapped in PermissionGate(admin.manage_users).
- Scan-receipt page surfaces an amber warning when OCR fails so reps
know they can fill the form manually instead of staring at a stalled
spinner; the editable form now also opens on scanMutation.isError
/ uploadedFile, not only on success.
- Email threads list now renders skeleton rows during load + shared
EmptyState for the empty case (was a single "Loading…" line).
Round 4 — Service / route correctness:
- documentSends.sent_by_user_id was a free-text NOT NULL column with no
FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row
survives a user being hard-deleted. Migration 0038 with a defensive
null-out for any orphan ids before attaching the constraint.
- Saved-views route: documented why withAuth alone is correct (the
service strictly filters by (portId, userId) — owner-only by design).
- Public-interests audit log: replaced "userId: null as unknown as
string" cast with userId: null; AuditLogParams already accepts null
for system-generated events.
- EOI in-app PDF fill: extracted setBerthRange() that, when the
AcroForm field is missing AND the context has a non-empty range
string, logs a structured warn so the deployment gap (live Documenso
template needs the field) is observable instead of silently dropping
the multi-berth range.
Test status: 1168/1168 vitest. tsc clean. Two new migrations
(0037/0038) need pnpm db:push (or migration apply) on the dev DB.
Deferred-doc updated with the remaining open items (bigger refactors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:49:53 +02:00
|
|
|
import { PermissionGate } from '@/components/shared/permission-gate';
|
2026-04-08 15:47:11 -04:00
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
2026-05-12 16:14:12 +02:00
|
|
|
import { formatRole } from '@/lib/constants';
|
feat(mobile): mobile cards for reminders, audit log, users
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.
- ReminderCard: Bell icon, related-entity subtitle (User/Anchor/
FileText icon by entity type), due-date meta with
past-due flag, accent bar (rose=past-due,
amber=pending, slate=snoozed, emerald=done).
Snooze/Complete/Edit/Delete in actions menu.
- AuditLogCard: Action icon (Plus/Pencil/Trash2/Eye), entity
title, "{verb} by {actor}" subtitle, timestamp
meta, optional changed-field chip line. Accent
bar by action (created=emerald, updated=blue,
deleted=rose). Immutable, no actions menu.
- UserCard: Initials avatar, displayName/email, role meta
(Shield icon), last-login distance, "Inactive"
pill when deactivated. Accent bar (violet=
super_admin, slate=inactive, none=active).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:06 +02:00
|
|
|
import { UserCard } from './user-card';
|
2026-04-08 15:47:11 -04:00
|
|
|
import { UserForm } from './user-form';
|
|
|
|
|
|
|
|
|
|
interface UserRow {
|
|
|
|
|
userId: string;
|
|
|
|
|
displayName: string;
|
|
|
|
|
email: string;
|
|
|
|
|
phone: string | null;
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
isSuperAdmin: boolean;
|
|
|
|
|
lastLoginAt: string | null;
|
|
|
|
|
role: { id: string; name: string };
|
|
|
|
|
assignedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function UserList() {
|
|
|
|
|
const [users, setUsers] = useState<UserRow[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
|
|
|
const [editingUser, setEditingUser] = useState<UserRow | null>(null);
|
|
|
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
2026-05-12 16:14:12 +02:00
|
|
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
2026-04-08 15:47:11 -04:00
|
|
|
|
|
|
|
|
const fetchUsers = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await apiFetch<{ data: UserRow[] }>('/api/v1/admin/users');
|
|
|
|
|
setUsers(res.data);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
void fetchUsers();
|
|
|
|
|
}, [fetchUsers]);
|
|
|
|
|
|
|
|
|
|
function handleNewUser() {
|
|
|
|
|
setEditingUser(null);
|
|
|
|
|
setFormOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleEditUser(user: UserRow) {
|
|
|
|
|
setEditingUser(user);
|
|
|
|
|
setFormOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleRemoveUser(userId: string) {
|
|
|
|
|
setDeletingId(userId);
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' });
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
} finally {
|
|
|
|
|
setDeletingId(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 16:14:12 +02:00
|
|
|
async function handleToggleActive(user: UserRow) {
|
|
|
|
|
setTogglingId(user.userId);
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: { isActive: !user.isActive },
|
|
|
|
|
});
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
} finally {
|
|
|
|
|
setTogglingId(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 15:47:11 -04:00
|
|
|
const columns: ColumnDef<UserRow, unknown>[] = [
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'displayName',
|
|
|
|
|
header: 'Name',
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium">{row.original.displayName}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">{row.original.email}</span>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'role',
|
|
|
|
|
header: 'Role',
|
2026-05-12 16:14:12 +02:00
|
|
|
cell: ({ row }) => <Badge variant="secondary">{formatRole(row.original.role.name)}</Badge>,
|
2026-04-08 15:47:11 -04:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'isActive',
|
|
|
|
|
header: 'Status',
|
|
|
|
|
cell: ({ row }) =>
|
|
|
|
|
row.original.isActive ? (
|
|
|
|
|
<Badge variant="default" className="bg-green-600">
|
|
|
|
|
<ShieldCheck className="mr-1 h-3 w-3" />
|
|
|
|
|
Active
|
|
|
|
|
</Badge>
|
|
|
|
|
) : (
|
|
|
|
|
<Badge variant="destructive">
|
|
|
|
|
<ShieldOff className="mr-1 h-3 w-3" />
|
|
|
|
|
Disabled
|
|
|
|
|
</Badge>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'lastLoginAt',
|
|
|
|
|
header: 'Last Login',
|
|
|
|
|
cell: ({ row }) =>
|
|
|
|
|
row.original.lastLoginAt
|
|
|
|
|
? new Date(row.original.lastLoginAt).toLocaleDateString()
|
|
|
|
|
: 'Never',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'actions',
|
|
|
|
|
header: '',
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<div className="flex items-center justify-end gap-1">
|
fix(audit-v3): platform-wide deferred-list cleanup (rounds 1-4)
Working through the audit-v2 deferred backlog. Each round was tested
(typecheck + 1168/1168 vitest) before moving on.
Round 1 — DB performance + AI cost visibility:
- Add missing FK indexes Postgres doesn't auto-create on
berth_reservations.{interest_id, contract_file_id},
documents.{file_id, signed_file_id}, document_events.signer_id,
document_templates.source_file_id, form_submissions.{form_template_id,
client_id}, document_sends.{brochure_id, brochure_version_id,
sent_by_user_id}. Without these, RESTRICT-checks on parent delete +
reverse-lookups walk the child tables fully. Migration 0037.
- AI worker now writes one ai_usage_ledger row per OpenAI call so admins
can audit spend per port/user/feature and future per-port budgets have
history to read from. Failure to write is logged-not-thrown so the
user-facing email draft is unaffected.
Round 2 — Boot-time + transport hardening:
- S3 backend verifies the bucket exists at startup (or auto-creates
when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now
surfaces with a clear boot error instead of a vague Minio error
inside the first user-facing request.
- Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx
+ network errors, fail-fast on 4xx. Stops one transient flake from
leaving a document with a partial field set.
- FilesystemBackend logs a structured warn-once at boot when the dev
HMAC fallback is in effect, so two processes started with different
BETTER_AUTH_SECRET values are observable (random 401s on file
downloads otherwise).
- Logger redact paths extended to cover *.headers.{authorization,
cookie}, *.config.headers.authorization, encrypted-credential blobs
(secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso
X-Documenso-Secret header, and 2-level nested forms.
Round 3 — UI feedback + permission gates:
- Storage admin migrate dialog: success toast with row count + error
toast on both dryRun and migrate mutations.
- Invoice detail Send + Record-payment buttons wrapped in
PermissionGate (invoices.send / invoices.record_payment); both
mutations now toast on success/error.
- Admin user list Edit button wrapped in PermissionGate(admin.manage_users).
- Scan-receipt page surfaces an amber warning when OCR fails so reps
know they can fill the form manually instead of staring at a stalled
spinner; the editable form now also opens on scanMutation.isError
/ uploadedFile, not only on success.
- Email threads list now renders skeleton rows during load + shared
EmptyState for the empty case (was a single "Loading…" line).
Round 4 — Service / route correctness:
- documentSends.sent_by_user_id was a free-text NOT NULL column with no
FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row
survives a user being hard-deleted. Migration 0038 with a defensive
null-out for any orphan ids before attaching the constraint.
- Saved-views route: documented why withAuth alone is correct (the
service strictly filters by (portId, userId) — owner-only by design).
- Public-interests audit log: replaced "userId: null as unknown as
string" cast with userId: null; AuditLogParams already accepts null
for system-generated events.
- EOI in-app PDF fill: extracted setBerthRange() that, when the
AcroForm field is missing AND the context has a non-empty range
string, logs a structured warn so the deployment gap (live Documenso
template needs the field) is observable instead of silently dropping
the multi-berth range.
Test status: 1168/1168 vitest. tsc clean. Two new migrations
(0037/0038) need pnpm db:push (or migration apply) on the dev DB.
Deferred-doc updated with the remaining open items (bigger refactors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:49:53 +02:00
|
|
|
<PermissionGate resource="admin" action="manage_users">
|
2026-05-12 16:14:12 +02:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleEditUser(row.original)}
|
|
|
|
|
title="Edit user"
|
|
|
|
|
>
|
fix(audit-v3): platform-wide deferred-list cleanup (rounds 1-4)
Working through the audit-v2 deferred backlog. Each round was tested
(typecheck + 1168/1168 vitest) before moving on.
Round 1 — DB performance + AI cost visibility:
- Add missing FK indexes Postgres doesn't auto-create on
berth_reservations.{interest_id, contract_file_id},
documents.{file_id, signed_file_id}, document_events.signer_id,
document_templates.source_file_id, form_submissions.{form_template_id,
client_id}, document_sends.{brochure_id, brochure_version_id,
sent_by_user_id}. Without these, RESTRICT-checks on parent delete +
reverse-lookups walk the child tables fully. Migration 0037.
- AI worker now writes one ai_usage_ledger row per OpenAI call so admins
can audit spend per port/user/feature and future per-port budgets have
history to read from. Failure to write is logged-not-thrown so the
user-facing email draft is unaffected.
Round 2 — Boot-time + transport hardening:
- S3 backend verifies the bucket exists at startup (or auto-creates
when MINIO_AUTO_CREATE_BUCKET=true). A typo'd bucket name now
surfaces with a clear boot error instead of a vague Minio error
inside the first user-facing request.
- Documenso v1 placeFields: 3-attempt exponential-backoff retry on 5xx
+ network errors, fail-fast on 4xx. Stops one transient flake from
leaving a document with a partial field set.
- FilesystemBackend logs a structured warn-once at boot when the dev
HMAC fallback is in effect, so two processes started with different
BETTER_AUTH_SECRET values are observable (random 401s on file
downloads otherwise).
- Logger redact paths extended to cover *.headers.{authorization,
cookie}, *.config.headers.authorization, encrypted-credential blobs
(secretKeyEncrypted, smtpPassEncrypted, etc.), the Documenso
X-Documenso-Secret header, and 2-level nested forms.
Round 3 — UI feedback + permission gates:
- Storage admin migrate dialog: success toast with row count + error
toast on both dryRun and migrate mutations.
- Invoice detail Send + Record-payment buttons wrapped in
PermissionGate (invoices.send / invoices.record_payment); both
mutations now toast on success/error.
- Admin user list Edit button wrapped in PermissionGate(admin.manage_users).
- Scan-receipt page surfaces an amber warning when OCR fails so reps
know they can fill the form manually instead of staring at a stalled
spinner; the editable form now also opens on scanMutation.isError
/ uploadedFile, not only on success.
- Email threads list now renders skeleton rows during load + shared
EmptyState for the empty case (was a single "Loading…" line).
Round 4 — Service / route correctness:
- documentSends.sent_by_user_id was a free-text NOT NULL column with no
FK. Now nullable + FK to user(id) ON DELETE SET NULL so the audit row
survives a user being hard-deleted. Migration 0038 with a defensive
null-out for any orphan ids before attaching the constraint.
- Saved-views route: documented why withAuth alone is correct (the
service strictly filters by (portId, userId) — owner-only by design).
- Public-interests audit log: replaced "userId: null as unknown as
string" cast with userId: null; AuditLogParams already accepts null
for system-generated events.
- EOI in-app PDF fill: extracted setBerthRange() that, when the
AcroForm field is missing AND the context has a non-empty range
string, logs a structured warn so the deployment gap (live Documenso
template needs the field) is observable instead of silently dropping
the multi-berth range.
Test status: 1168/1168 vitest. tsc clean. Two new migrations
(0037/0038) need pnpm db:push (or migration apply) on the dev DB.
Deferred-doc updated with the remaining open items (bigger refactors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:49:53 +02:00
|
|
|
<Pencil className="h-4 w-4" />
|
|
|
|
|
<span className="sr-only">Edit</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</PermissionGate>
|
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
|
|
|
<PermissionGate resource="admin" action="manage_users">
|
|
|
|
|
<ConfirmationDialog
|
|
|
|
|
trigger={
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2026-05-12 16:14:12 +02:00
|
|
|
title={row.original.isActive ? 'Disable sign-in' : 'Enable sign-in'}
|
|
|
|
|
disabled={togglingId === row.original.userId}
|
|
|
|
|
className={
|
|
|
|
|
row.original.isActive
|
|
|
|
|
? 'text-muted-foreground hover:text-foreground'
|
|
|
|
|
: 'text-emerald-600 hover:text-emerald-700'
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{row.original.isActive ? (
|
|
|
|
|
<PowerOff className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<Power className="h-4 w-4" />
|
|
|
|
|
)}
|
2026-05-12 16:52:35 +02:00
|
|
|
<span className="sr-only">{row.original.isActive ? 'Disable' : 'Enable'}</span>
|
2026-05-12 16:14:12 +02:00
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
title={row.original.isActive ? 'Disable user' : 'Enable user'}
|
|
|
|
|
description={
|
|
|
|
|
row.original.isActive
|
|
|
|
|
? `Disable sign-in for "${row.original.displayName}"? Their account stays intact; they just can't log in until you re-enable.`
|
|
|
|
|
: `Re-enable sign-in for "${row.original.displayName}"?`
|
|
|
|
|
}
|
|
|
|
|
confirmLabel={row.original.isActive ? 'Disable' : 'Enable'}
|
|
|
|
|
onConfirm={() => handleToggleActive(row.original)}
|
|
|
|
|
loading={togglingId === row.original.userId}
|
|
|
|
|
/>
|
|
|
|
|
</PermissionGate>
|
|
|
|
|
<PermissionGate resource="admin" action="manage_users">
|
|
|
|
|
<ConfirmationDialog
|
|
|
|
|
trigger={
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
title="Remove from port"
|
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
|
|
|
className="text-destructive hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
<span className="sr-only">Remove</span>
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
title="Remove User"
|
|
|
|
|
description={`Remove "${row.original.displayName}" from this port? They will lose access but their account remains.`}
|
|
|
|
|
confirmLabel="Remove"
|
|
|
|
|
onConfirm={() => handleRemoveUser(row.original.userId)}
|
|
|
|
|
loading={deletingId === row.original.userId}
|
|
|
|
|
/>
|
|
|
|
|
</PermissionGate>
|
2026-04-08 15:47:11 -04:00
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
enableSorting: false,
|
2026-05-12 16:14:12 +02:00
|
|
|
size: 120,
|
2026-04-08 15:47:11 -04:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<PageHeader
|
|
|
|
|
title="User Management"
|
|
|
|
|
description="Manage users and their roles for this port"
|
|
|
|
|
actions={
|
|
|
|
|
<Button onClick={handleNewUser}>
|
|
|
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
|
|
|
New User
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<DataTable
|
|
|
|
|
columns={columns}
|
|
|
|
|
data={users}
|
|
|
|
|
isLoading={loading}
|
|
|
|
|
getRowId={(row) => row.userId}
|
feat(mobile): mobile cards for reminders, audit log, users
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.
- ReminderCard: Bell icon, related-entity subtitle (User/Anchor/
FileText icon by entity type), due-date meta with
past-due flag, accent bar (rose=past-due,
amber=pending, slate=snoozed, emerald=done).
Snooze/Complete/Edit/Delete in actions menu.
- AuditLogCard: Action icon (Plus/Pencil/Trash2/Eye), entity
title, "{verb} by {actor}" subtitle, timestamp
meta, optional changed-field chip line. Accent
bar by action (created=emerald, updated=blue,
deleted=rose). Immutable, no actions menu.
- UserCard: Initials avatar, displayName/email, role meta
(Shield icon), last-login distance, "Inactive"
pill when deactivated. Accent bar (violet=
super_admin, slate=inactive, none=active).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:06 +02:00
|
|
|
cardRender={(row) => (
|
|
|
|
|
<UserCard
|
|
|
|
|
user={row.original}
|
|
|
|
|
onEdit={handleEditUser}
|
|
|
|
|
onRemove={handleRemoveUser}
|
2026-05-12 16:14:12 +02:00
|
|
|
onToggleActive={handleToggleActive}
|
feat(mobile): mobile cards for reminders, audit log, users
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.
- ReminderCard: Bell icon, related-entity subtitle (User/Anchor/
FileText icon by entity type), due-date meta with
past-due flag, accent bar (rose=past-due,
amber=pending, slate=snoozed, emerald=done).
Snooze/Complete/Edit/Delete in actions menu.
- AuditLogCard: Action icon (Plus/Pencil/Trash2/Eye), entity
title, "{verb} by {actor}" subtitle, timestamp
meta, optional changed-field chip line. Accent
bar by action (created=emerald, updated=blue,
deleted=rose). Immutable, no actions menu.
- UserCard: Initials avatar, displayName/email, role meta
(Shield icon), last-login distance, "Inactive"
pill when deactivated. Accent bar (violet=
super_admin, slate=inactive, none=active).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:06 +02:00
|
|
|
isRemoving={deletingId === row.original.userId}
|
2026-05-12 16:14:12 +02:00
|
|
|
isToggling={togglingId === row.original.userId}
|
feat(mobile): mobile cards for reminders, audit log, users
Three new <EntityCard> files using the shared <ListCard> shell, wired
into each list page's <DataTable> via cardRender.
- ReminderCard: Bell icon, related-entity subtitle (User/Anchor/
FileText icon by entity type), due-date meta with
past-due flag, accent bar (rose=past-due,
amber=pending, slate=snoozed, emerald=done).
Snooze/Complete/Edit/Delete in actions menu.
- AuditLogCard: Action icon (Plus/Pencil/Trash2/Eye), entity
title, "{verb} by {actor}" subtitle, timestamp
meta, optional changed-field chip line. Accent
bar by action (created=emerald, updated=blue,
deleted=rose). Immutable, no actions menu.
- UserCard: Initials avatar, displayName/email, role meta
(Shield icon), last-login distance, "Inactive"
pill when deactivated. Accent bar (violet=
super_admin, slate=inactive, none=active).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:39:06 +02:00
|
|
|
/>
|
|
|
|
|
)}
|
2026-04-08 15:47:11 -04:00
|
|
|
emptyState={
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
<p className="text-muted-foreground">No users assigned to this port.</p>
|
|
|
|
|
<Button variant="link" onClick={handleNewUser} className="mt-2">
|
|
|
|
|
Add the first user
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<UserForm
|
|
|
|
|
open={formOpen}
|
|
|
|
|
onOpenChange={setFormOpen}
|
|
|
|
|
user={editingUser}
|
|
|
|
|
onSuccess={fetchUsers}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|