login.tsx 9.0 KB

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