feat(users): per-user signing-email override (login ≠ signing identity)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m3s
Build & Push Docker Images / build-and-push (push) Successful in 8m51s

Adds nullable user_profiles.signing_email — the address representing a user
in SIGNING contexts only (EOI developer/approver signer slot, in-CRM 'your
turn to sign' notification, 'awaiting my signature' hub tab). Never a login
credential; better-auth identity stays single-email. NULL → user signs as
their own login email.

Lets a person who logs in as e.g. abbie@ sign on behalf of a shared role
mailbox (sales@) without changing their login. Additive + backward-compatible:
every existing user (NULL override) is byte-for-byte unaffected.

- migration 0100 + drizzle column (nullable, no unique constraint)
- resolveCrmUser: signing_email ?? user.email
- listDocuments hub filter: match the caller's owned email SET (login +
  override) for awaiting_me / awaiting_them (currentUserEmail -> currentUserEmails)
- ctx.user.signingEmail surfaced from the already-loaded profile (no extra query)
- updateUser persists/lowercases the override; '' clears it (edit-only)
- admin Edit-User form: optional 'Signing email' field
- TDD: validator unit test + 3 integration tests (resolver override+fallback,
  updateUser persist/clear, hub awaiting_me matches override)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
This commit is contained in:
2026-06-26 15:08:44 +02:00
parent b2692839f1
commit 9f5810e3df
15 changed files with 385 additions and 20 deletions

View File

@@ -20,7 +20,7 @@ export function makeMockCtx(opts: MockCtxOptions): AuthContext {
portSlug: 'test-port',
isSuperAdmin: opts.isSuperAdmin ?? false,
permissions: opts.permissions ?? null,
user: { email: 'test@example.com', name: 'Test User' },
user: { email: 'test@example.com', name: 'Test User', signingEmail: null },
ipAddress: '127.0.0.1',
userAgent: 'vitest/1.0',
};

View File

@@ -0,0 +1,72 @@
/**
* The documents-hub "awaiting my signature" tab matches a document's pending
* signer against the set of emails the caller owns — their login email AND
* their signing_email override. So a user who logs in as `abbie@` but signs as
* `sales@` still sees the `sales@`-signer documents in their personal tab.
*/
import { describe, it, expect } from 'vitest';
import { db } from '@/lib/db';
import { documents, documentSigners } from '@/lib/db/schema/documents';
import { listDocuments } from '@/lib/services/documents.service';
import { makePort, makeClient } from '../helpers/factories';
async function seedPendingDoc(portId: string, clientId: string, signerEmail: string) {
const [doc] = await db
.insert(documents)
.values({
portId,
clientId,
documentType: 'eoi',
title: 'EOI awaiting signature',
status: 'sent',
createdBy: 'seed',
})
.returning();
await db.insert(documentSigners).values({
documentId: doc!.id,
signerName: 'Approver',
signerEmail,
signerRole: 'approver',
signingOrder: 1,
status: 'pending',
});
return doc!.id;
}
const baseQuery = {
page: 1,
limit: 50,
sort: 'createdAt' as const,
order: 'desc' as const,
includeArchived: false,
tab: 'awaiting_me' as const,
};
describe('documents hub - awaiting_me matches owned email set', () => {
it('includes a doc whose pending signer matches the caller signing_email (not their login)', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const docId = await seedPendingDoc(port.id, client.id, 'sales@portnimara.com');
const result = await listDocuments(port.id, baseQuery, {
currentUserEmails: ['abbie@portnimara.com', 'sales@portnimara.com'],
});
const ids = (result.data as Array<{ id: string }>).map((d) => d.id);
expect(ids).toContain(docId);
});
it('excludes the doc when the caller owns neither the signer email', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const docId = await seedPendingDoc(port.id, client.id, 'sales@portnimara.com');
const result = await listDocuments(port.id, baseQuery, {
currentUserEmails: ['abbie@portnimara.com'],
});
const ids = (result.data as Array<{ id: string }>).map((d) => d.id);
expect(ids).not.toContain(docId);
});
});

View File

@@ -0,0 +1,88 @@
/**
* The EOI signer slot, when linked to a CRM user, resolves that user's
* SIGNING identity — `user_profiles.signing_email` when set, otherwise the
* login `user.email`. This lets a person who logs in as `abbie@` sign on
* behalf of the shared `sales@` role mailbox without changing their login.
*/
import { afterAll, describe, expect, it, vi } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { account, roles, user, userProfiles, systemSettings } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createUser } from '@/lib/services/users.service';
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
import { makePort, makeAuditMeta } from '../helpers/factories';
describe('getPortEoiSigners - signing_email override', () => {
const createdUserIds: string[] = [];
afterAll(async () => {
for (const id of createdUserIds) {
await db.delete(account).where(eq(account.userId, id));
await db.delete(userProfiles).where(eq(userProfiles.userId, id));
await db.delete(user).where(eq(user.id, id));
}
});
async function salesRoleId(): Promise<string> {
const r = await db.query.roles.findFirst({ where: eq(roles.name, 'sales_manager') });
if (!r) throw new Error('sales_manager role not seeded — run pnpm db:seed');
return r.id;
}
async function makeSignerUser(loginEmail: string): Promise<string> {
const resetSpy = vi
.spyOn(auth.api, 'requestPasswordReset')
.mockResolvedValue({ status: true } as never);
try {
const port = await makePort();
const created = await createUser(
port.id,
{
email: loginEmail,
name: 'Abbie May',
displayName: 'Abbie May',
roleId: await salesRoleId(),
sendSetupEmail: true,
residentialAccess: false,
},
makeAuditMeta(),
);
createdUserIds.push(created.userId);
return created.userId;
} finally {
resetSpy.mockRestore();
}
}
it('resolves the linked signer email from signing_email when set', async () => {
const port = await makePort();
const userId = await makeSignerUser(`login-${Date.now()}-a@example.test`);
await db
.update(userProfiles)
.set({ signingEmail: 'sales@portnimara.com' })
.where(eq(userProfiles.userId, userId));
await db
.insert(systemSettings)
.values({ portId: port.id, key: 'documenso_approver_user_id', value: userId });
const signers = await getPortEoiSigners(port.id);
expect(signers.approver.email).toBe('sales@portnimara.com');
expect(signers.approver.name).toBe('Abbie May');
});
it('falls back to the login email when signing_email is null', async () => {
const port = await makePort();
const loginEmail = `login-${Date.now()}-b@example.test`;
const userId = await makeSignerUser(loginEmail);
await db
.insert(systemSettings)
.values({ portId: port.id, key: 'documenso_approver_user_id', value: userId });
const signers = await getPortEoiSigners(port.id);
expect(signers.approver.email).toBe(loginEmail);
});
});

View File

@@ -33,7 +33,7 @@ function makeCtx(overrides: Partial<AuthContext>): AuthContext {
portSlug: 'test-port',
isSuperAdmin: false,
permissions: makeViewerPermissions(),
user: { email: 'test@example.com', name: 'Test User' },
user: { email: 'test@example.com', name: 'Test User', signingEmail: null },
ipAddress: '127.0.0.1',
userAgent: 'vitest/1.0',
...overrides,

View File

@@ -0,0 +1,77 @@
/**
* updateUser persists the optional signing-identity override
* (user_profiles.signing_email): lowercased on the way in, and cleared to NULL
* when an empty string is supplied.
*/
import { afterAll, describe, expect, it, vi } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { account, roles, user, userProfiles } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createUser, updateUser } from '@/lib/services/users.service';
import { makePort, makeAuditMeta } from '../helpers/factories';
describe('updateUser - signing_email override', () => {
const createdUserIds: string[] = [];
afterAll(async () => {
for (const id of createdUserIds) {
await db.delete(account).where(eq(account.userId, id));
await db.delete(userProfiles).where(eq(userProfiles.userId, id));
await db.delete(user).where(eq(user.id, id));
}
});
async function makeUser(): Promise<{ userId: string; portId: string }> {
const resetSpy = vi
.spyOn(auth.api, 'requestPasswordReset')
.mockResolvedValue({ status: true } as never);
try {
const port = await makePort();
const role = await db.query.roles.findFirst({ where: eq(roles.name, 'sales_manager') });
if (!role) throw new Error('sales_manager role not seeded');
const created = await createUser(
port.id,
{
email: `signemail-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@example.test`,
name: 'Abbie May',
displayName: 'Abbie May',
roleId: role.id,
sendSetupEmail: true,
residentialAccess: false,
},
makeAuditMeta(),
);
createdUserIds.push(created.userId);
return { userId: created.userId, portId: port.id };
} finally {
resetSpy.mockRestore();
}
}
async function readSigningEmail(userId: string): Promise<string | null> {
const row = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, userId),
});
return row?.signingEmail ?? null;
}
it('persists a signing email, lowercased', async () => {
const { userId, portId } = await makeUser();
await updateUser(userId, portId, { signingEmail: 'SALES@PortNimara.com' }, makeAuditMeta());
expect(await readSigningEmail(userId)).toBe('sales@portnimara.com');
});
it('clears the override when an empty string is supplied', async () => {
const { userId, portId } = await makeUser();
await updateUser(userId, portId, { signingEmail: 'sales@portnimara.com' }, makeAuditMeta());
expect(await readSigningEmail(userId)).toBe('sales@portnimara.com');
await updateUser(userId, portId, { signingEmail: '' }, makeAuditMeta());
expect(await readSigningEmail(userId)).toBeNull();
});
});

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { updateUserSchema } from '@/lib/validators/users';
describe('updateUserSchema.signingEmail', () => {
it('accepts and preserves a valid signing email', () => {
const parsed = updateUserSchema.parse({ signingEmail: 'sales@portnimara.com' });
expect(parsed.signingEmail).toBe('sales@portnimara.com');
});
it('allows an empty string (sentinel for "clear the override")', () => {
const parsed = updateUserSchema.parse({ signingEmail: '' });
expect(parsed.signingEmail).toBe('');
});
it('rejects a malformed signing email', () => {
expect(() => updateUserSchema.parse({ signingEmail: 'not-an-email' })).toThrow();
});
});