+ The address used to represent this user in document signing (signer slot,
+ signing notifications, “awaiting my signature” tab). Lets them sign
+ on behalf of a shared mailbox while keeping their own sign-in email. Leave blank
+ to sign as their login email.
+
+
+ )}
+
{!isEdit && (
<>
diff --git a/src/lib/api/helpers.ts b/src/lib/api/helpers.ts
index 9a90b040..05da30e1 100644
--- a/src/lib/api/helpers.ts
+++ b/src/lib/api/helpers.ts
@@ -44,6 +44,10 @@ export interface AuthContext {
user: {
email: string;
name: string;
+ /** Optional signing-identity override (user_profiles.signing_email). The
+ * address representing this user in signing contexts; null when they sign
+ * as their login email. Never a login credential. */
+ signingEmail: string | null;
};
/** Client IP extracted from X-Forwarded-For header. */
ipAddress: string;
@@ -272,6 +276,7 @@ export function withAuth>(
user: {
email: session.user.email,
name: session.user.name,
+ signingEmail: profile.signingEmail ?? null,
},
ipAddress: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
userAgent: req.headers.get('user-agent') ?? 'unknown',
diff --git a/src/lib/db/migrations/0100_user_profiles_signing_email.sql b/src/lib/db/migrations/0100_user_profiles_signing_email.sql
new file mode 100644
index 00000000..db781231
--- /dev/null
+++ b/src/lib/db/migrations/0100_user_profiles_signing_email.sql
@@ -0,0 +1,13 @@
+-- Per-user signing-identity override.
+--
+-- A nullable address used to represent the user in SIGNING contexts only:
+-- the EOI developer/approver signer slot (resolveCrmUser), the in-CRM
+-- "your turn to sign" notification, and the "awaiting my signature" hub tab.
+-- NEVER a login credential — better-auth identity stays single-email via
+-- user.email / account.account_id. NULL → user signs as their own login email.
+--
+-- Additive + backward-compatible: every existing row defaults to NULL and is
+-- byte-for-byte unaffected. No uniqueness constraint — a shared role mailbox
+-- (e.g. sales@) may legitimately be referenced by one CRM user.
+
+ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS signing_email text;
diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts
index 0550d239..bdea6ba6 100644
--- a/src/lib/db/schema/users.ts
+++ b/src/lib/db/schema/users.ts
@@ -309,6 +309,17 @@ export const userProfiles = pgTable(
* coexist with users who still sign in by email. See migration 0054.
*/
username: text('username'),
+ /**
+ * Optional signing-identity override. When set, this is the address the
+ * system uses to represent the user in SIGNING contexts only — the EOI
+ * developer/approver signer slot (`resolveCrmUser`), the in-CRM "your turn
+ * to sign" notification target, and the "awaiting my signature" hub tab.
+ * It is NEVER a login credential (better-auth stays single-email via
+ * `user.email` / `account.account_id`). NULL → the user signs as their
+ * own login email. Lets a person (e.g. login `abbie@`) sign on behalf of a
+ * shared role mailbox (e.g. `sales@`). See migration 0100.
+ */
+ signingEmail: text('signing_email'),
avatarUrl: text('avatar_url'),
/** FK into the polymorphic `files` table - the avatar is stored
* via getStorageBackend() so an S3↔filesystem swap carries it
diff --git a/src/lib/services/documenso-payload.ts b/src/lib/services/documenso-payload.ts
index e03b586b..9d1fed46 100644
--- a/src/lib/services/documenso-payload.ts
+++ b/src/lib/services/documenso-payload.ts
@@ -149,7 +149,11 @@ function isSignerEntry(v: unknown): v is { name: string; email: string } {
}
/** Look up `{name, email}` for a CRM user id by joining `userProfiles`
- * (display name) + `user` (auth email). Returns nulls on miss. */
+ * (display name + signing override) + `user` (auth/login email). The signing
+ * email is `userProfiles.signingEmail` when set, otherwise the login
+ * `user.email` — so a user who logs in as `abbie@` can sign on behalf of a
+ * shared role mailbox (`sales@`) without changing their login identity.
+ * Returns nulls on miss. */
async function resolveCrmUser(
userId: string | null,
): Promise<{ name: string; email: string } | null> {
@@ -157,14 +161,17 @@ async function resolveCrmUser(
const [row] = await db
.select({
displayName: userProfiles.displayName,
- email: user.email,
+ loginEmail: user.email,
+ signingEmail: userProfiles.signingEmail,
})
.from(user)
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
.where(eq(user.id, userId))
.limit(1);
- if (!row || !row.email) return null;
- return { name: row.displayName ?? row.email, email: row.email };
+ if (!row) return null;
+ const email = row.signingEmail ?? row.loginEmail;
+ if (!email) return null;
+ return { name: row.displayName ?? email, email };
}
/**
diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts
index e143a4b0..2b9e5f96 100644
--- a/src/lib/services/documents.service.ts
+++ b/src/lib/services/documents.service.ts
@@ -1,4 +1,18 @@
-import { and, desc, eq, gte, inArray, isNull, lt, lte, ne, or, sql, exists } from 'drizzle-orm';
+import {
+ and,
+ desc,
+ eq,
+ gte,
+ inArray,
+ isNull,
+ lt,
+ lte,
+ ne,
+ notInArray,
+ or,
+ sql,
+ exists,
+} from 'drizzle-orm';
import { db } from '@/lib/db';
import {
@@ -78,7 +92,11 @@ const NON_SIGNATURE_TYPES = [
function buildHubTabFilters(
tab: ListDocumentsInput['tab'],
- currentUserEmail: string | undefined,
+ // The set of addresses the caller "owns" for signing purposes: their login
+ // email plus their signing_email override (if any). A pending signer row
+ // counts as the caller's when its email is in this set. Empty → no caller
+ // identity, so "awaiting me / them" can't be distinguished.
+ currentUserEmails: string[],
): ReturnType[] {
const filters: ReturnType[] = [];
if (!tab || tab === 'all') return filters;
@@ -99,10 +117,10 @@ function buildHubTabFilters(
filters.push(inArray(documents.status, ['draft', 'sent', 'partially_signed']));
break;
case 'awaiting_them':
- // "awaiting them" = pending signers other than the current user.
- // Without a known caller email we cannot make that distinction, so
- // short-circuit to empty rather than silently widen the result set.
- if (!currentUserEmail) {
+ // "awaiting them" = pending signers that are NOT the caller (none of the
+ // caller's owned emails). Without a known caller identity we cannot make
+ // that distinction, so short-circuit to empty rather than widen.
+ if (currentUserEmails.length === 0) {
filters.push(sql`1 = 0`);
break;
}
@@ -116,15 +134,15 @@ function buildHubTabFilters(
and(
eq(documentSigners.documentId, documents.id),
eq(documentSigners.status, 'pending'),
- ne(documentSigners.signerEmail, currentUserEmail),
+ notInArray(documentSigners.signerEmail, currentUserEmails),
),
),
),
);
break;
case 'awaiting_me':
- if (!currentUserEmail) {
- // Without a current-user email there is no concept of "awaiting me"
+ if (currentUserEmails.length === 0) {
+ // Without a caller identity there is no concept of "awaiting me"
filters.push(sql`1 = 0`);
break;
}
@@ -137,7 +155,7 @@ function buildHubTabFilters(
and(
eq(documentSigners.documentId, documents.id),
eq(documentSigners.status, 'pending'),
- eq(documentSigners.signerEmail, currentUserEmail),
+ inArray(documentSigners.signerEmail, currentUserEmails),
),
),
),
@@ -157,8 +175,10 @@ function buildHubTabFilters(
}
export interface ListDocumentsExtra {
- /** Email of the calling user - used by hub tab filtering for "awaiting me". */
- currentUserEmail?: string;
+ /** The addresses the calling user owns for signing — login email plus their
+ * signing_email override (if set). Used by hub tab filtering for "awaiting
+ * me / them". */
+ currentUserEmails?: string[];
}
export async function listDocuments(
@@ -236,7 +256,7 @@ export async function listDocuments(
);
}
- filters.push(...buildHubTabFilters(tab, extra.currentUserEmail));
+ filters.push(...buildHubTabFilters(tab, extra.currentUserEmails ?? []));
void NON_SIGNATURE_TYPES;
void lt;
diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts
index c7a9b3db..6983830f 100644
--- a/src/lib/services/users.service.ts
+++ b/src/lib/services/users.service.ts
@@ -29,6 +29,7 @@ export async function listUsers(portId: string) {
lastName: userProfiles.lastName,
fullName: user.name,
email: user.email,
+ signingEmail: userProfiles.signingEmail,
phone: userProfiles.phone,
isActive: userProfiles.isActive,
isSuperAdmin: userProfiles.isSuperAdmin,
@@ -51,6 +52,7 @@ export async function listUsers(portId: string) {
lastName: userProfiles.lastName,
fullName: user.name,
email: user.email,
+ signingEmail: userProfiles.signingEmail,
phone: userProfiles.phone,
isActive: userProfiles.isActive,
isSuperAdmin: userProfiles.isSuperAdmin,
@@ -72,6 +74,7 @@ export async function listUsers(portId: string) {
lastName: row.lastName,
fullName: row.fullName,
email: row.email,
+ signingEmail: row.signingEmail,
phone: row.phone,
isActive: row.isActive,
isSuperAdmin: row.isSuperAdmin,
@@ -127,6 +130,7 @@ export async function getUser(userId: string, portId: string) {
lastName: profile.lastName,
fullName: authUser?.name ?? null,
email: authUser?.email ?? '',
+ signingEmail: profile.signingEmail,
phone: profile.phone,
isActive: profile.isActive,
isSuperAdmin: profile.isSuperAdmin,
@@ -317,6 +321,12 @@ export async function updateUser(
if (data.lastName !== undefined) profileUpdates.lastName = data.lastName;
if (data.phone !== undefined) profileUpdates.phone = data.phone;
if (data.isActive !== undefined) profileUpdates.isActive = data.isActive;
+ // Signing-identity override. Empty string is the "clear it" sentinel; any
+ // non-empty value is lowercased (the validator already guaranteed email
+ // shape). NEVER touches auth identity — this is signing-only.
+ if (data.signingEmail !== undefined) {
+ profileUpdates.signingEmail = data.signingEmail === '' ? null : data.signingEmail.toLowerCase();
+ }
if (Object.keys(profileUpdates).length > 1) {
await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
diff --git a/src/lib/validators/users.ts b/src/lib/validators/users.ts
index 059af9d1..d726392b 100644
--- a/src/lib/validators/users.ts
+++ b/src/lib/validators/users.ts
@@ -41,6 +41,12 @@ export const updateUserSchema = z.object({
* sign-in email" notification to the prior address. UI sets this when
* the admin confirms the warning dialog. */
notifyEmailChange: z.boolean().optional(),
+ /** Optional signing-identity override — the address used to represent this
+ * user in signing contexts (EOI signer slot, signing notifications,
+ * "awaiting my signature" hub tab). NEVER a login credential. Empty string
+ * clears it; a non-empty value must be a valid email. The service lowercases
+ * the value and maps '' → null. */
+ signingEmail: z.union([z.literal(''), z.string().email()]).optional(),
phone: z.string().nullable().optional(),
isActive: z.boolean().optional(),
roleId: z.string().uuid().optional(),
diff --git a/tests/helpers/route-tester.ts b/tests/helpers/route-tester.ts
index 94f4da8f..0eebc571 100644
--- a/tests/helpers/route-tester.ts
+++ b/tests/helpers/route-tester.ts
@@ -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',
};
diff --git a/tests/integration/documents-hub-awaiting-me-signing-email.test.ts b/tests/integration/documents-hub-awaiting-me-signing-email.test.ts
new file mode 100644
index 00000000..78540ad3
--- /dev/null
+++ b/tests/integration/documents-hub-awaiting-me-signing-email.test.ts
@@ -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);
+ });
+});
diff --git a/tests/integration/eoi-signer-signing-email.test.ts b/tests/integration/eoi-signer-signing-email.test.ts
new file mode 100644
index 00000000..d6b3fa92
--- /dev/null
+++ b/tests/integration/eoi-signer-signing-email.test.ts
@@ -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 {
+ 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 {
+ 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);
+ });
+});
diff --git a/tests/integration/permission-matrix.test.ts b/tests/integration/permission-matrix.test.ts
index b1c511bd..3d534a62 100644
--- a/tests/integration/permission-matrix.test.ts
+++ b/tests/integration/permission-matrix.test.ts
@@ -33,7 +33,7 @@ function makeCtx(overrides: Partial): 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,
diff --git a/tests/integration/update-user-signing-email.test.ts b/tests/integration/update-user-signing-email.test.ts
new file mode 100644
index 00000000..f5e67701
--- /dev/null
+++ b/tests/integration/update-user-signing-email.test.ts
@@ -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 {
+ 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();
+ });
+});
diff --git a/tests/unit/users-validators.test.ts b/tests/unit/users-validators.test.ts
new file mode 100644
index 00000000..c8e5f16b
--- /dev/null
+++ b/tests/unit/users-validators.test.ts
@@ -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();
+ });
+});