login.tsx290 lines · main
1import { useIsLoggedIn, useParams } from 'common'
2import { Terminal } from 'lucide-react'
3import Head from 'next/head'
4import Link from 'next/link'
5import { useRouter } from 'next/router'
6import { useEffect, useRef, useState, type ReactNode } from 'react'
7import { Button, Card, CardContent } from 'ui'
8import { Admonition, ShimmeringLoader } from 'ui-patterns'
9
10import {
11 InterstitialAccountRow,
12 InterstitialLayout,
13 LogoBox,
14 LogoPair,
15 BrivenLogo,
16} from '@/components/layouts/InterstitialLayout'
17import CopyButton from '@/components/ui/CopyButton'
18import { InlineLink } from '@/components/ui/InlineLink'
19import { createCliLoginSession } from '@/data/cli/login'
20import { withAuth } from '@/hooks/misc/withAuth'
21import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
22import { buildStudioPageTitle } from '@/lib/page-title'
23import { useProfile } from '@/lib/profile'
24import type { NextPageWithLayout } from '@/types'
25
26const PAGE_TITLE = buildStudioPageTitle({ section: 'Authorize CLI', brand: 'Briven' })
27
28const CliLogo = () => (
29 <LogoBox className="bg-black">
30 <Terminal className="size-6 text-white" strokeWidth={2} />
31 </LogoBox>
32)
33
34const CliLoginInterstitial = ({
35 title,
36 description,
37 children,
38}: {
39 title: ReactNode
40 description?: ReactNode
41 children: ReactNode
42}) => (
43 <InterstitialLayout
44 logo={<LogoPair left={<CliLogo />} right={<BrivenLogo />} />}
45 title={title}
46 description={description}
47 >
48 <div className="px-6 pb-6">{children}</div>
49 </InterstitialLayout>
50)
51
52function getErrorMessage(error: unknown): string {
53 if (error instanceof Error) return error.message
54 if (
55 typeof error === 'object' &&
56 error !== null &&
57 'message' in error &&
58 typeof (error as { message: unknown }).message === 'string'
59 ) {
60 return (error as { message: string }).message
61 }
62 return 'Unknown error'
63}
64
65const CliLoginPage: NextPageWithLayout = () => {
66 const router = useRouter()
67 const { session_id, public_key, token_name, device_code } = useParams()
68 const isLoggedIn = useIsLoggedIn()
69
70 if (!router.isReady) return null
71
72 return (
73 <>
74 <Head>
75 <title>{PAGE_TITLE}</title>
76 </Head>
77 <CliLoginScreen
78 isLoggedIn={isLoggedIn}
79 routerReady={router.isReady}
80 sessionId={session_id}
81 publicKey={public_key}
82 tokenName={token_name}
83 deviceCode={device_code}
84 navigate={(destination) => router.push(destination)}
85 />
86 </>
87 )
88}
89
90type CliLoginStatus =
91 | { _tag: 'loading' }
92 | { _tag: 'ready'; deviceCode: string }
93 | { _tag: 'missing-params'; missingParameters: string[] }
94 | { _tag: 'error'; message?: string }
95
96export const CliLoginScreen = ({
97 isLoggedIn,
98 routerReady,
99 sessionId,
100 publicKey,
101 tokenName,
102 deviceCode,
103 navigate: navigateProp,
104}: {
105 isLoggedIn: boolean
106 routerReady: boolean
107 sessionId?: string
108 publicKey?: string
109 tokenName?: string
110 deviceCode?: string
111 navigate: (destination: string) => void
112}) => {
113 const { profile } = useProfile()
114 const [status, setStatus] = useState<CliLoginStatus>({ _tag: 'loading' })
115 const startedForSessionIdRef = useRef<string | undefined>(undefined)
116 // Keep navigate in a ref so changing the prop never re-triggers the effect
117 // or cancels an in-flight POST via the isActive cleanup.
118 const navigate = useStaticEffectEvent(navigateProp)
119 const displayName = profile?.primary_email ?? profile?.username
120
121 useEffect(() => {
122 if (!isLoggedIn || !routerReady) return
123 if (deviceCode) {
124 setStatus({ _tag: 'ready', deviceCode })
125 return
126 }
127
128 const missingParameters = [
129 !sessionId ? 'session_id' : undefined,
130 !publicKey ? 'public_key' : undefined,
131 ].filter(Boolean) as string[]
132
133 if (missingParameters.length > 0) {
134 setStatus({ _tag: 'missing-params', missingParameters })
135 return
136 }
137
138 // Guard against re-render loops triggered by unstable deps (e.g. a new
139 // `navigate` reference on each parent render) firing the POST more than
140 // once per session_id. Without this, the dashboard creates several
141 // identical access tokens before navigating to the device_code view.
142 if (startedForSessionIdRef.current === sessionId) return
143 startedForSessionIdRef.current = sessionId
144
145 let isActive = true
146 setStatus({ _tag: 'loading' })
147
148 async function createSession() {
149 try {
150 const { nonce } = await createCliLoginSession(sessionId!, publicKey!, tokenName)
151
152 if (!isActive) return
153
154 if (nonce) {
155 navigate(`/cli/login?device_code=${nonce.substring(0, 8)}`)
156 } else {
157 setStatus({ _tag: 'error', message: 'The CLI sign-in session did not return a code.' })
158 }
159 } catch (error: unknown) {
160 if (!isActive) return
161 setStatus({ _tag: 'error', message: getErrorMessage(error) })
162 }
163 }
164
165 createSession()
166
167 return () => {
168 isActive = false
169 }
170 }, [deviceCode, isLoggedIn, publicKey, routerReady, sessionId, tokenName, navigate])
171
172 if (status._tag === 'loading') {
173 return (
174 <CliLoginInterstitial
175 title={<ShimmeringLoader className="mx-auto h-7 w-32 max-w-full py-0" />}
176 description={<ShimmeringLoader className="mx-auto h-4 w-56 max-w-full py-0" />}
177 >
178 <div className="flex flex-col gap-5">
179 <Card className="shadow-none">
180 <CardContent className="flex items-center gap-3 border-none px-4 py-3">
181 <ShimmeringLoader className="size-8 shrink-0 rounded-full py-0" />
182 <div className="min-w-0 flex-1 space-y-2">
183 <ShimmeringLoader className="h-3 w-20 py-0" />
184 <ShimmeringLoader className="h-4 w-40 max-w-full py-0" />
185 </div>
186 </CardContent>
187 </Card>
188 <ShimmeringLoader className="h-20 w-full rounded-lg py-0" />
189 </div>
190 </CliLoginInterstitial>
191 )
192 }
193
194 if (status._tag === 'missing-params') {
195 const isPlural = status.missingParameters.length > 1
196
197 return (
198 <CliLoginInterstitial
199 title="Missing sign-in parameters"
200 description="This Briven CLI sign-in request cannot be authorized"
201 >
202 <div className="flex flex-col gap-3">
203 <Admonition
204 type="warning"
205 description={`Open the browser sign-in flow from Briven CLI again. The URL is missing parameter${
206 isPlural ? 's' : ''
207 }: ${status.missingParameters.join(', ')}.`}
208 />
209 <Button type="default" block asChild>
210 <Link href="/organizations">Back to dashboard</Link>
211 </Button>
212 </div>
213 </CliLoginInterstitial>
214 )
215 }
216
217 if (status._tag === 'error') {
218 return (
219 <CliLoginInterstitial
220 title="Unable to create CLI sign-in"
221 description="Retry the sign-in command from Briven CLI"
222 >
223 <div className="flex flex-col gap-3">
224 <Admonition
225 type="warning"
226 description={
227 <>
228 Briven could not create the CLI sign-in session.
229 {status.message && (
230 <span className="mt-1 block text-foreground-lighter">
231 Error: {status.message}
232 </span>
233 )}
234 </>
235 }
236 />
237 <Button type="default" block asChild>
238 <Link href="/organizations">Back to dashboard</Link>
239 </Button>
240 </div>
241 </CliLoginInterstitial>
242 )
243 }
244
245 return (
246 <CliLoginInterstitial
247 title="Authorize Briven CLI"
248 description="Enter this verification code in Briven CLI to finish signing in"
249 >
250 <div className="flex flex-col gap-5">
251 <div className="flex flex-col items-center gap-3">
252 <div
253 aria-label={`Verification code ${status.deviceCode}`}
254 className="flex w-full select-text items-center font-sans text-xl text-foreground"
255 onCopy={(event) => {
256 event.preventDefault()
257 event.clipboardData.setData('text/plain', status.deviceCode)
258 }}
259 >
260 {Array.from(status.deviceCode.padEnd(8, ' ')).map((character, index) => (
261 <span
262 key={index}
263 className="flex h-11 flex-1 cursor-text select-text items-center justify-center border-y border-r border-input first:rounded-l-md first:border-l last:rounded-r-md"
264 >
265 {character}
266 </span>
267 ))}
268 </div>
269 <CopyButton
270 text={status.deviceCode}
271 copyLabel="Copy code"
272 copiedLabel="Copied"
273 type="primary"
274 size="tiny"
275 className="w-full"
276 />
277 </div>
278
279 <InterstitialAccountRow displayName={displayName} />
280
281 <p className="text-center text-xs text-foreground-lighter text-balance">
282 After authorizing, you can close this tab or manage tokens like this one in{' '}
283 <InlineLink href="/account/tokens">Access Tokens</InlineLink>.
284 </p>
285 </div>
286 </CliLoginInterstitial>
287 )
288}
289
290export default withAuth(CliLoginPage)