feat(users): per-user signing-email override (login ≠ signing identity)
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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-signing-email">Signing email (optional)</Label>
|
||||
<Input
|
||||
id="user-signing-email"
|
||||
type="email"
|
||||
value={signingEmail}
|
||||
onChange={(e) => setSigningEmail(e.target.value)}
|
||||
placeholder="e.g. sales@portnimara.com"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
|
||||
@@ -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<TParams extends RouteParams = Record<string, string>>(
|
||||
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',
|
||||
|
||||
13
src/lib/db/migrations/0100_user_profiles_signing_email.sql
Normal file
13
src/lib/db/migrations/0100_user_profiles_signing_email.sql
Normal file
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<typeof and>[] {
|
||||
const filters: ReturnType<typeof and>[] = [];
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
88
tests/integration/eoi-signer-signing-email.test.ts
Normal file
88
tests/integration/eoi-signer-signing-email.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
77
tests/integration/update-user-signing-email.test.ts
Normal file
77
tests/integration/update-user-signing-email.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
tests/unit/users-validators.test.ts
Normal file
19
tests/unit/users-validators.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user