diff --git a/src/app/api/v1/documents/route.ts b/src/app/api/v1/documents/route.ts index c33819b3..69887fe3 100644 --- a/src/app/api/v1/documents/route.ts +++ b/src/app/api/v1/documents/route.ts @@ -25,7 +25,10 @@ export const GET = withAuth( } const result = await listDocuments(ctx.portId, query, { - currentUserEmail: ctx.user.email, + // The caller's owned signing identities: their login email plus their + // signing_email override (if set), so the "awaiting me" tab still + // matches documents signed under a shared role mailbox. + currentUserEmails: [ctx.user.email, ctx.user.signingEmail].filter((e): e is string => !!e), }); const { page, limit } = query; diff --git a/src/components/admin/users/user-form.tsx b/src/components/admin/users/user-form.tsx index 80bc79aa..0d1dc972 100644 --- a/src/components/admin/users/user-form.tsx +++ b/src/components/admin/users/user-form.tsx @@ -46,6 +46,7 @@ interface UserFormProps { firstName?: string | null; lastName?: string | null; email: string; + signingEmail?: string | null; phone: string | null; isActive: boolean; role: { id: string; name: string }; @@ -88,6 +89,11 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { const [email, setEmail] = useState(user?.email ?? ''); const [originalEmail] = useState(user?.email ?? ''); const [emailConfirmOpen, setEmailConfirmOpen] = useState(false); + // Optional signing-identity override (edit-only). The address that + // represents this user in signing contexts (EOI signer slot, signing + // notifications, "awaiting my signature" tab) without changing their login. + const [signingEmail, setSigningEmail] = useState(user?.signingEmail ?? ''); + const [originalSigningEmail] = useState(user?.signingEmail ?? ''); const [password, setPassword] = useState(''); // New users: email them a set-password link by default rather than typing a // password here. Toggle off to set one manually. @@ -137,6 +143,12 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { fullName: fullName || displayName, displayName, email: emailChanged ? email.trim() : undefined, + // Send the signing override only when it changed. Empty string is + // the explicit "clear it" sentinel the API understands. + signingEmail: + signingEmail.trim().toLowerCase() !== originalSigningEmail.toLowerCase() + ? signingEmail.trim().toLowerCase() + : undefined, phone: phoneE164, roleId, isActive, @@ -283,6 +295,28 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) { ) : null} + {isEdit && ( +
+ + setSigningEmail(e.target.value)} + placeholder="e.g. sales@portnimara.com" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +

+ 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(); + }); +});