11import { CheckCircleIcon } from "@heroicons/react/24/solid" ;
22import { Form } from "@remix-run/react" ;
3+ import { useState } from "react" ;
34import type { ActionFunctionArgs , LoaderFunctionArgs } from "@remix-run/server-runtime" ;
45import { typedjson , useTypedActionData , useTypedLoaderData } from "remix-typedjson" ;
56import { z } from "zod" ;
67import { AppContainer , MainCenteredContainer } from "~/components/layout/AppLayout" ;
78import { Button } from "~/components/primitives/Buttons" ;
89import { Callout } from "~/components/primitives/Callout" ;
10+ import { CheckboxWithLabel } from "~/components/primitives/Checkbox" ;
911import { Header1 } from "~/components/primitives/Headers" ;
1012import { Icon } from "~/components/primitives/Icon" ;
1113import { Paragraph } from "~/components/primitives/Paragraph" ;
14+ import { TextLink } from "~/components/primitives/TextLink" ;
1215import { prisma } from "~/db.server" ;
16+ import { env } from "~/env.server" ;
17+ import {
18+ compareCliLoginNetworks ,
19+ describeIpLocation ,
20+ needsNetworkConfirmation ,
21+ } from "~/services/cliLoginNetwork.server" ;
1322import { logger } from "~/services/logger.server" ;
1423import {
1524 createPersonalAccessTokenFromAuthorizationCode ,
16- isAuthorizationCodeMintable ,
25+ getMintableAuthorizationCode ,
1726} from "~/services/personalAccessToken.server" ;
27+ import { getIpLocation } from "~/services/platform.v3.server" ;
28+ import { scheduleEmail } from "~/services/scheduleEmail.server" ;
1829import { requireUser } from "~/services/session.server" ;
30+ import { extractClientIp } from "~/utils/extractClientIp.server" ;
1931import { pageMeta } from "~/utils/pageTitle" ;
2032
2133export const meta = pageMeta ( "Authorize login" ) ;
@@ -57,28 +69,42 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5769 const { authorizationCode } = parseParams ( params ) ;
5870 const { source, clientName } = parseSearch ( request ) ;
5971
60- const mintable = await isAuthorizationCodeMintable ( authorizationCode ) ;
72+ const mintable = await getMintableAuthorizationCode ( authorizationCode ) ;
6173
6274 if ( ! mintable ) {
6375 return typedjson ( { status : "invalid" as const , source, clientName } ) ;
6476 }
6577
6678 // The token acts as the user, so it can reach every organization they belong to.
6779 const organizationWhere = { deletedAt : null , members : { some : { userId : user . id } } } ;
68- const [ organizations , organizationCount ] = await Promise . all ( [
80+ const [ organizations , organizationCount , requesterLocation ] = await Promise . all ( [
6981 prisma . organization . findMany ( {
7082 select : { id : true , title : true } ,
7183 where : organizationWhere ,
7284 orderBy : { title : "asc" } ,
7385 take : ORGANIZATION_DISPLAY_LIMIT ,
7486 } ) ,
7587 prisma . organization . count ( { where : organizationWhere } ) ,
88+ getIpLocation ( mintable . requesterIp ) ,
7689 ] ) ;
7790
91+ const networkMatch = compareCliLoginNetworks (
92+ mintable . requesterIp ,
93+ extractClientIp ( request . headers . get ( "x-forwarded-for" ) )
94+ ) ;
95+
7896 return typedjson ( {
7997 status : "consent" as const ,
8098 source,
8199 clientName,
100+ requesterIp : mintable . requesterIp ,
101+ requesterLocation : describeIpLocation ( requesterLocation ) ?? null ,
102+ startedMinutesAgo : Math . max (
103+ 0 ,
104+ Math . floor ( ( Date . now ( ) - mintable . createdAt . getTime ( ) ) / 60_000 )
105+ ) ,
106+ requiresNetworkConfirmation :
107+ env . CLI_LOGIN_NETWORK_CHECK === "confirm" && needsNetworkConfirmation ( networkMatch ) ,
82108 account : {
83109 email : user . email ,
84110 name : user . displayName ?? user . name ,
@@ -89,13 +115,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
89115} ;
90116
91117export const action = async ( { request, params } : ActionFunctionArgs ) => {
92- const { id : userId } = await requireUser ( request ) ;
118+ const user = await requireUser ( request ) ;
119+ const userId = user . id ;
93120
94121 const { authorizationCode } = parseParams ( params ) ;
95122 const { source, clientName } = parseSearch ( request ) ;
123+ const formData = await request . formData ( ) ;
96124
97125 try {
98- await createPersonalAccessTokenFromAuthorizationCode ( authorizationCode , userId ) ;
126+ const token = await createPersonalAccessTokenFromAuthorizationCode ( authorizationCode , userId , {
127+ approverIp : extractClientIp ( request . headers . get ( "x-forwarded-for" ) ) ,
128+ confirmedDifferentNetwork : formData . get ( "confirmDifferentNetwork" ) === "on" ,
129+ } ) ;
130+
131+ await notifyLoginAuthorized ( {
132+ userEmail : user . email ,
133+ clientDescription : describeClient ( source , clientName ) ,
134+ obfuscatedToken : token . obfuscatedToken ,
135+ requesterIp : token . requesterIp ,
136+ } ) ;
137+
99138 return typedjson ( { success : true as const , source, clientName } ) ;
100139 } catch ( error ) {
101140 if ( error instanceof Response ) {
@@ -169,14 +208,24 @@ export default function Page() {
169208 < div className = "flex flex-col gap-4" >
170209 < Header1 > Authorize login</ Header1 >
171210 < Paragraph > { getConsentPrompt ( loaderData . source , loaderData . clientName ) } </ Paragraph >
172- < Form method = "post" >
173- < Button type = "submit" variant = "primary/medium" fullWidth >
174- Authorize
175- </ Button >
176- </ Form >
211+ < Paragraph variant = "small" >
212+ { describeLoginStart (
213+ loaderData . startedMinutesAgo ,
214+ loaderData . requesterIp ,
215+ loaderData . requesterLocation
216+ ) }
217+ </ Paragraph >
218+ < ConsentForm requiresNetworkConfirmation = { loaderData . requiresNetworkConfirmation } />
177219 < Paragraph variant = "extra-small" >
178220 Only authorize if you started this login yourself. If you didn't, close this page.
179221 </ Paragraph >
222+ { loaderData . requesterLocation && (
223+ < Paragraph variant = "extra-small" >
224+ < TextLink href = "https://ipinfo.io" target = "_blank" >
225+ IP address data powered by IPinfo
226+ </ TextLink >
227+ </ Paragraph >
228+ ) }
180229 < div className = "flex flex-col gap-3 rounded-md border border-grid-bright p-3" >
181230 < div >
182231 < Paragraph variant = "base/bright" className = "font-medium" >
@@ -209,6 +258,88 @@ export default function Page() {
209258 ) ;
210259}
211260
261+ function ConsentForm ( { requiresNetworkConfirmation } : { requiresNetworkConfirmation : boolean } ) {
262+ const [ confirmed , setConfirmed ] = useState ( false ) ;
263+
264+ return (
265+ < Form method = "post" className = "flex flex-col gap-3" >
266+ { requiresNetworkConfirmation && (
267+ < >
268+ < Callout variant = "warning" >
269+ This login was started from a different network than the one you're on now. If someone
270+ sent you this link, close this page.
271+ </ Callout >
272+ < CheckboxWithLabel
273+ name = "confirmDifferentNetwork"
274+ value = "on"
275+ variant = "simple/small"
276+ label = "I started this login myself, on another machine"
277+ onChange = { setConfirmed }
278+ />
279+ </ >
280+ ) }
281+ < Button
282+ type = "submit"
283+ variant = "primary/medium"
284+ fullWidth
285+ disabled = { requiresNetworkConfirmation && ! confirmed }
286+ >
287+ Authorize
288+ </ Button >
289+ </ Form >
290+ ) ;
291+ }
292+
293+ function describeLoginStart (
294+ startedMinutesAgo : number ,
295+ requesterIp : string | null ,
296+ requesterLocation : string | null
297+ ) {
298+ const when =
299+ startedMinutesAgo === 0
300+ ? "just now"
301+ : `${ startedMinutesAgo } minute${ startedMinutesAgo === 1 ? "" : "s" } ago` ;
302+ if ( ! requesterIp ) {
303+ return `This login was started ${ when } .` ;
304+ }
305+ return requesterLocation
306+ ? `This login was started ${ when } from IP address ${ requesterIp } (${ requesterLocation } ).`
307+ : `This login was started ${ when } from IP address ${ requesterIp } .` ;
308+ }
309+
310+ function describeClient ( source : string , clientName : string ) {
311+ if ( source === "mcp" ) {
312+ return knownClientName ( clientName ) ?? "an MCP client" ;
313+ }
314+ return "the Trigger.dev CLI" ;
315+ }
316+
317+ async function notifyLoginAuthorized ( details : {
318+ userEmail : string ;
319+ clientDescription : string ;
320+ obfuscatedToken : string ;
321+ requesterIp : string | null ;
322+ } ) {
323+ try {
324+ const requesterLocation = describeIpLocation ( await getIpLocation ( details . requesterIp ) ) ;
325+ await scheduleEmail ( {
326+ email : "cli-login-authorized" ,
327+ to : details . userEmail ,
328+ userEmail : details . userEmail ,
329+ clientDescription : details . clientDescription ,
330+ obfuscatedToken : details . obfuscatedToken ,
331+ requesterIp : details . requesterIp ?? undefined ,
332+ requesterLocation,
333+ authorizedAt : new Date ( ) . toUTCString ( ) ,
334+ tokensUrl : `${ env . APP_ORIGIN } /account/tokens` ,
335+ } ) ;
336+ } catch ( error ) {
337+ logger . error ( "Failed to schedule the CLI login authorized email" , {
338+ error : error instanceof Error ? error . message : String ( error ) ,
339+ } ) ;
340+ }
341+ }
342+
212343function AuthShell ( { children } : { children : React . ReactNode } ) {
213344 return (
214345 < AppContainer >
@@ -227,10 +358,14 @@ const prettyClientNames: Record<string, string> = {
227358 "claude-ai" : "Claude Desktop" ,
228359} ;
229360
361+ function knownClientName ( clientName : string ) : string | undefined {
362+ return Object . hasOwn ( prettyClientNames , clientName ) ? prettyClientNames [ clientName ] : undefined ;
363+ }
364+
230365function getConsentPrompt ( source : string , clientName : string ) {
231366 if ( source === "mcp" ) {
232- const pretty = prettyClientNames [ clientName ] ?? clientName ;
233- if ( pretty && pretty !== "unknown" ) {
367+ const pretty = knownClientName ( clientName ) ;
368+ if ( pretty ) {
234369 return `Authorize ${ pretty } to access your Trigger.dev account?` ;
235370 }
236371 return `Authorize this MCP client to access your Trigger.dev account?` ;
@@ -241,9 +376,8 @@ function getConsentPrompt(source: string, clientName: string) {
241376
242377function getInstructionsForSource ( source : string , clientName : string ) {
243378 if ( source === "mcp" ) {
244- if ( clientName ) {
245- return `Return to your ${ prettyClientNames [ clientName ] ?? clientName } to continue.` ;
246- }
379+ const pretty = knownClientName ( clientName ) ;
380+ return pretty ? `Return to ${ pretty } to continue.` : `Return to your MCP client to continue.` ;
247381 }
248382
249383 return `Return to your terminal to continue.` ;
0 commit comments