From 03a752172953068371a98f883bf8f9f4164dbd1d Mon Sep 17 00:00:00 2001
From: Matt
Date: Thu, 21 May 2026 23:02:33 +0200
Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20Groups=20J=20+=20K=20?=
=?UTF-8?q?=E2=80=94=20activity=20feed=20+=20onboarding=20resolver-chain?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
J38, J39, K40 (core) from the 2026-05-21 plan.
Shipped:
J38 EntityActivityFeed sentence rendering surfaces the new value
inline. Was " updated the X"; now " set X to
" when the audit row carries `newValue`. Field-level
diff line underneath keeps showing the old → new strikethrough
for context. Truncates inline value at 60 chars to keep long
notes / descriptions from blowing out the row.
J39 Client → Companies tab CTA. Empty state gains a "Link to a
company" action; populated state grows a top-right "Link to
company" button. New wraps the existing
+ a membership-role select + an "is primary"
checkbox, then POSTs to /api/v1/companies/[id]/members.
Empty-state copy dropped "Add a membership from a company's
detail page" — the rep can act inline now.
K40 OnboardingChecklist resolver-chain. The auto-check no longer
reads raw `/admin/settings` rows (which miss env fallbacks).
Resolved endpoint widened to accept `?keys=k1,k2,...` so the
checklist can batch-resolve any heterogenous set of registry
keys through port → global → env → default in one round-trip.
Checklist captures the dominant source per step ("env fallback",
"global default", "built-in default") and surfaces it inline
under the green tick so super-admins see when a step is
relying on env rather than a per-port override. Compound-key
gates report the weakest sub-key's source so a partially-env
config still flags clearly.
Topbar banner / dashboard tile / weekly nudge / celebration
sub-items remain queued — the core resolver-chain gap was
the actual cause of the "step never ticks" UAT complaint.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../api/v1/admin/settings/resolved/route.ts | 39 ++++-
src/components/admin/onboarding-checklist.tsx | 92 ++++++++--
.../clients/client-companies-tab.tsx | 160 +++++++++++++++++-
.../shared/entity-activity-feed.tsx | 31 +++-
4 files changed, 293 insertions(+), 29 deletions(-)
diff --git a/src/app/api/v1/admin/settings/resolved/route.ts b/src/app/api/v1/admin/settings/resolved/route.ts
index fe44f10d..33e15f4a 100644
--- a/src/app/api/v1/admin/settings/resolved/route.ts
+++ b/src/app/api/v1/admin/settings/resolved/route.ts
@@ -2,16 +2,20 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
-import { entriesForSections } from '@/lib/settings/registry';
+import { entriesForSections, registryFor } from '@/lib/settings/registry';
import { resolveForAdminAPI } from '@/lib/settings/resolver';
/**
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
+ * GET /api/v1/admin/settings/resolved?keys=branding_logo_url,smtp_host_override
*
* Returns the resolved value + source (port/global/env/default) for every
- * registry entry in the requested sections. Drives the registry-driven
- * admin form: the `source` field gates the "Using env fallback" badge.
+ * requested registry entry. Drives both the registry-driven admin form
+ * (sections param) and the onboarding-checklist auto-detection (keys
+ * param) — both need port→global→env→default resolution rather than the
+ * raw `/admin/settings` rows (which only show DB writes).
*
+ * Either parameter is supported; if both are present the sets union.
* Sensitive fields surface `isSet` only — never the decrypted value.
*/
export const GET = withAuth(
@@ -19,14 +23,33 @@ export const GET = withAuth(
try {
const url = new URL(req.url);
const sectionsParam = url.searchParams.get('sections');
- if (!sectionsParam) {
+ const keysParam = url.searchParams.get('keys');
+ if (!sectionsParam && !keysParam) {
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
}
const sections = sectionsParam
- .split(',')
- .map((s) => s.trim())
- .filter(Boolean);
- const entries = entriesForSections(sections);
+ ? sectionsParam
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : [];
+ const extraKeys = keysParam
+ ? keysParam
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : [];
+ const sectionEntries = entriesForSections(sections);
+ const keyEntries = extraKeys
+ .map((k) => registryFor(k))
+ .filter((e): e is NonNullable => Boolean(e));
+ // Dedupe by `key` so section + key overlap doesn't double-resolve.
+ const seen = new Set();
+ const entries = [...sectionEntries, ...keyEntries].filter((e) => {
+ if (seen.has(e.key)) return false;
+ seen.add(e.key);
+ return true;
+ });
const keys = entries.map((e) => e.key);
const resolved = await resolveForAdminAPI(keys, ctx.portId);
diff --git a/src/components/admin/onboarding-checklist.tsx b/src/components/admin/onboarding-checklist.tsx
index e39a2c63..f1ec7da7 100644
--- a/src/components/admin/onboarding-checklist.tsx
+++ b/src/components/admin/onboarding-checklist.tsx
@@ -112,6 +112,14 @@ const STEPS: OnboardingStep[] = [
},
];
+interface ResolvedValue {
+ isSet: boolean;
+ source?: 'port' | 'global' | 'env' | 'default' | 'none';
+ value?: unknown;
+}
+interface ResolvedResp {
+ data: { entries: Array<{ key: string }>; values: Record };
+}
interface SettingRow {
key: string;
value: unknown;
@@ -125,6 +133,11 @@ export function OnboardingChecklist() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const [autoChecks, setAutoChecks] = useState>({});
+ // Per-step source flags — populated for steps whose auto-check resolved
+ // via the env / default fallback rather than a port / global override.
+ // Surfaces "Resolving from env" copy so super admins see what's
+ // backing each green tick without digging into the settings page.
+ const [autoSources, setAutoSources] = useState>({});
const [manualChecks, setManualChecks] = useState>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(null);
@@ -133,23 +146,40 @@ export function OnboardingChecklist() {
async function load() {
setLoading(true);
try {
- const settings = await apiFetch('/api/v1/admin/settings');
- const all = [...settings.data.portSettings, ...settings.data.globalSettings];
- const byKey = new Map(all.map((r) => [r.key, r.value]));
-
- const isPresent = (v: unknown) => v !== undefined && v !== null && v !== '' && v !== false;
+ // Collect every setting key referenced by the checklist so we can
+ // batch-resolve them through the full chain in one round-trip.
+ // The `resolved` endpoint reads port→global→env→default, so a
+ // port using env-only credentials still auto-ticks (the old
+ // raw `/admin/settings` query missed env fallback entirely).
+ const keys = new Set();
+ for (const s of STEPS) {
+ if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey);
+ if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k);
+ }
+ // Manual-checkbox state still lives in the raw system_settings
+ // row (it's a JSON blob, not a per-key registry entry) — keep
+ // fetching it the old way.
+ const [resolved, settings] = await Promise.all([
+ keys.size > 0
+ ? apiFetch(
+ `/api/v1/admin/settings/resolved?keys=${Array.from(keys)
+ .map(encodeURIComponent)
+ .join(',')}`,
+ )
+ : Promise.resolve({ data: { entries: [], values: {} } } as ResolvedResp),
+ apiFetch('/api/v1/admin/settings'),
+ ]);
+ const values = resolved.data.values;
+ const isPresent = (key: string): boolean => Boolean(values[key]?.isSet);
const checks: Record = {};
const listChecks = await Promise.all(
STEPS.map(async (s) => {
if (s.autoCheckSettingKey) {
- return [s.id, isPresent(byKey.get(s.autoCheckSettingKey))] as const;
+ return [s.id, isPresent(s.autoCheckSettingKey)] as const;
}
if (s.autoCheckSettingKeysAll) {
- return [
- s.id,
- s.autoCheckSettingKeysAll.every((k) => isPresent(byKey.get(k))),
- ] as const;
+ return [s.id, s.autoCheckSettingKeysAll.every((k) => isPresent(k))] as const;
}
if (s.autoCheckListEndpoint) {
try {
@@ -165,7 +195,35 @@ export function OnboardingChecklist() {
for (const [id, done] of listChecks) checks[id] = done;
setAutoChecks(checks);
+ // Capture the dominant source per step. For single-key checks this
+ // is the key's source; for multi-key checks we report the
+ // "weakest" source so the rep sees env if any sub-key is env.
+ const sourcesByStep: Record = {};
+ const PRIORITY: Record = {
+ port: 4,
+ global: 3,
+ env: 2,
+ default: 1,
+ none: 0,
+ };
+ for (const s of STEPS) {
+ if (s.autoCheckSettingKey) {
+ sourcesByStep[s.id] = values[s.autoCheckSettingKey]?.source;
+ } else if (s.autoCheckSettingKeysAll) {
+ const sources = s.autoCheckSettingKeysAll
+ .map((k) => values[k]?.source)
+ .filter((x): x is NonNullable => Boolean(x));
+ // Pick the lowest-priority (weakest) source so the rep sees
+ // "env" if the compound has any env-only sub-key.
+ sources.sort((a, b) => (PRIORITY[a] ?? 0) - (PRIORITY[b] ?? 0));
+ sourcesByStep[s.id] = sources[0];
+ }
+ }
+ setAutoSources(sourcesByStep);
+
// Pull the manual-checkbox state from system_settings.
+ const allSettings = [...settings.data.portSettings, ...settings.data.globalSettings];
+ const byKey = new Map(allSettings.map((r) => [r.key, r.value]));
const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record;
setManualChecks(manual);
} finally {
@@ -254,6 +312,20 @@ export function OnboardingChecklist() {
step.autoCheckSettingKeysAll?.join(' + ') ??
step.autoCheckListEndpoint}
+ {autoSources[step.id] && autoSources[step.id] !== 'port' ? (
+
+ · resolving from{' '}
+
+ {autoSources[step.id] === 'env'
+ ? 'env fallback'
+ : autoSources[step.id] === 'global'
+ ? 'global default'
+ : autoSources[step.id] === 'default'
+ ? 'built-in default'
+ : autoSources[step.id]}
+
+
+ ) : null}
)}
diff --git a/src/components/clients/client-companies-tab.tsx b/src/components/clients/client-companies-tab.tsx
index ada6f283..18be40b2 100644
--- a/src/components/clients/client-companies-tab.tsx
+++ b/src/components/clients/client-companies-tab.tsx
@@ -1,8 +1,12 @@
'use client';
+import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { format } from 'date-fns';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Building2, Plus } from 'lucide-react';
+import { toast } from 'sonner';
import {
Table,
@@ -13,7 +17,27 @@ import {
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/shared/empty-state';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { CompanyPicker } from '@/components/companies/company-picker';
+import { apiFetch } from '@/lib/api/client';
+import { toastError } from '@/lib/api/toast-error';
interface ClientCompaniesTabProps {
clientId: string;
@@ -37,22 +61,45 @@ function formatSince(startDate: string | Date): string {
return format(d, 'MMM d, yyyy');
}
-export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCompaniesTabProps) {
+export function ClientCompaniesTab({ clientId, companies }: ClientCompaniesTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
+ const [linkOpen, setLinkOpen] = useState(false);
if (companies.length === 0) {
return (
-
+ <>
+ setLinkOpen(true) }}
+ />
+
+ >
);
}
return (
-
Company affiliations
+
+
Company affiliations
+
+
+
@@ -101,3 +148,104 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
);
}
+
+interface LinkCompanyDialogProps {
+ open: boolean;
+ onOpenChange: (next: boolean) => void;
+ clientId: string;
+ portSlug: string;
+}
+
+const MEMBERSHIP_ROLES = [
+ { value: 'director', label: 'Director' },
+ { value: 'shareholder', label: 'Shareholder' },
+ { value: 'employee', label: 'Employee' },
+ { value: 'agent', label: 'Agent' },
+ { value: 'beneficial_owner', label: 'Beneficial owner' },
+ { value: 'authorised_signatory', label: 'Authorised signatory' },
+ { value: 'other', label: 'Other' },
+];
+
+function LinkCompanyDialog({ open, onOpenChange, clientId, portSlug }: LinkCompanyDialogProps) {
+ const [companyId, setCompanyId] = useState(null);
+ const [role, setRole] = useState('director');
+ const [isPrimary, setIsPrimary] = useState(false);
+ const qc = useQueryClient();
+
+ const create = useMutation({
+ mutationFn: async () => {
+ if (!companyId) throw new Error('Pick a company');
+ await apiFetch(`/api/v1/companies/${companyId}/members`, {
+ method: 'POST',
+ body: { clientId, role, isPrimary },
+ });
+ },
+ onSuccess: () => {
+ toast.success('Membership added');
+ void qc.invalidateQueries({ queryKey: ['clients', clientId] });
+ setCompanyId(null);
+ setRole('director');
+ setIsPrimary(false);
+ onOpenChange(false);
+ },
+ onError: (err) => toastError(err),
+ });
+
+ return (
+
+ );
+}
diff --git a/src/components/shared/entity-activity-feed.tsx b/src/components/shared/entity-activity-feed.tsx
index a0751b78..37e1f14b 100644
--- a/src/components/shared/entity-activity-feed.tsx
+++ b/src/components/shared/entity-activity-feed.tsx
@@ -65,15 +65,36 @@ function formatValueForField(field: string | null, value: unknown): string {
return JSON.stringify(value);
}
-/** Natural-sentence rendering of one audit row. Falls back to terse when
- * there's no field to talk about. */
+/** Natural-sentence rendering of one audit row.
+ *
+ * Behaviour ladder:
+ * 1. Field changed with both old + new values → "set to "
+ * (the inline diff below already shows the old-value strikethrough,
+ * so we don't restate it here; result reads like a sentence rather
+ * than a diff label).
+ * 2. Field changed with only a new value → "set to "
+ * 3. Field changed with neither value (rare; legacy rows) → "
+ * the "
+ * 4. No field → " this record"
+ *
+ * Truncation at 60 chars on the inline value keeps long body fields
+ * (notes, descriptions) from blowing out the row — the diff line
+ * below still renders the full value if the rep clicks through.
+ */
function sentence(row: AuditRow, actor: string): string {
const verb = actionVerb(row.action);
const field = formatField(row.fieldChanged);
- if (field) {
- return `${actor} ${verb} the ${field}`;
+ if (!field) return `${actor} ${verb} this record`;
+
+ const newFormatted =
+ row.newValue !== null && row.newValue !== undefined
+ ? formatValueForField(row.fieldChanged, row.newValue)
+ : null;
+ if (newFormatted) {
+ const truncated = newFormatted.length > 60 ? newFormatted.slice(0, 60) + '…' : newFormatted;
+ return `${actor} set ${field} to "${truncated}"`;
}
- return `${actor} ${verb} this record`;
+ return `${actor} ${verb} the ${field}`;
}
interface Props {