@@ -121,11 +121,22 @@ export async function action({ request }: ActionFunctionArgs) {
121121
122122 const user = where ? await prisma . user . findFirst ( { where, include : userInclude } ) : null ;
123123
124+ /**
125+ * Impersonation is offered only when the customer was matched on `externalId` — a value we set
126+ * ourselves from `User.id`.
127+ *
128+ * Matching on email is a weaker claim: the address on a Plain customer isn't verified, and for
129+ * customers created outside our own writes it comes from whoever sent the message. Offering a
130+ * one-click impersonation link off the back of that would let an unverified address stand in
131+ * for an account, so email-matched customers get the account rows without it.
132+ */
133+ const canImpersonate = ! ! customer . externalId ;
134+
124135 // No matching user: still answer every requested key, with no data so Plain hides the cards.
125136 if ( ! user ) {
137+ // Presence flags only — the identifiers themselves don't need to persist in log storage.
126138 logger . info ( "User not found for Plain customer card request" , {
127- customerId : customer . id ,
128- externalId : customer . externalId ,
139+ hasExternalId : ! ! customer . externalId ,
129140 hasEmail : ! ! customer . email ,
130141 } ) ;
131142 return json ( { cards : answerAllCardKeys ( cardKeys , [ ] ) } ) ;
@@ -138,10 +149,21 @@ export async function action({ request }: ActionFunctionArgs) {
138149 for ( const cardKey of cardKeys ) {
139150 switch ( cardKey ) {
140151 case accountDetailsKey : {
141- // Generate a signed one-time token for impersonation
142- const impersonationToken = await generateImpersonationToken ( user . id ) ;
143- // Build the impersonate URL with token for CSRF protection
144- const impersonateUrl = `${ env . APP_ORIGIN } /admin/impersonate?impersonate=${ user . id } &impersonationToken=${ encodeURIComponent ( impersonationToken ) } ` ;
152+ // Only mint a token when the button will actually be rendered — see `canImpersonate`.
153+ const impersonationComponents = canImpersonate
154+ ? [
155+ uiComponent . spacer ( { size : "M" } ) ,
156+ uiComponent . divider ( { spacingSize : "M" } ) ,
157+ uiComponent . spacer ( { size : "M" } ) ,
158+ uiComponent . linkButton ( {
159+ label : "Impersonate User" ,
160+ // The one-time token is what protects this link against CSRF.
161+ url : `${ env . APP_ORIGIN } /admin/impersonate?impersonate=${ user . id } &impersonationToken=${ encodeURIComponent (
162+ await generateImpersonationToken ( user . id )
163+ ) } `,
164+ } ) ,
165+ ]
166+ : [ ] ;
145167
146168 cards . push ( {
147169 key : accountDetailsKey ,
@@ -221,13 +243,7 @@ export async function action({ request }: ActionFunctionArgs) {
221243 } ) ,
222244 ] ,
223245 } ) ,
224- uiComponent . spacer ( { size : "M" } ) ,
225- uiComponent . divider ( { spacingSize : "M" } ) ,
226- uiComponent . spacer ( { size : "M" } ) ,
227- uiComponent . linkButton ( {
228- label : "Impersonate User" ,
229- url : impersonateUrl ,
230- } ) ,
246+ ...impersonationComponents ,
231247 ] ,
232248 } ) ,
233249 ] ,
0 commit comments