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

@@ -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;

View File

@@ -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, &ldquo;awaiting my signature&rdquo; 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">

View File

@@ -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',

View 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;

View File

@@ -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

View File

@@ -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 };
}
/**

View File

@@ -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;

View File

@@ -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));

View File

@@ -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(),