|
1 | | -import { useState } from 'react' |
| 1 | +import { useEffect, useRef, useState } from 'react' |
2 | 2 | import { useLocation, useNavigate } from 'react-router-dom' |
3 | 3 | import { Brand } from '../components/Common' |
4 | | -import { setToken, clearToken, fetchMe } from '../api' |
| 4 | +import { setToken, clearToken, fetchMe, getToken, completeCliSession } from '../api' |
5 | 5 | import { postAuthDestination } from '../lib/postAuthDestination' |
6 | 6 |
|
7 | 7 | const OAUTH_API_BASE_DEFAULT = 'https://api.instanode.dev' |
@@ -56,6 +56,43 @@ export function LoginPage() { |
56 | 56 | const [emailSent, setEmailSent] = useState(false) |
57 | 57 | const [emailErr, setEmailErr] = useState<string | null>(null) |
58 | 58 |
|
| 59 | + // D2 — already-signed-in CLI device-flow completion. |
| 60 | + // |
| 61 | + // When a developer who is ALREADY authenticated runs `instant login`, the |
| 62 | + // CLI opens /login?cli_session=<id> and polls. The fresh-OAuth/magic-link |
| 63 | + // path completes the device flow in LoginCallbackPage (it POSTs |
| 64 | + // /auth/cli/{id}/complete after sign-in) — but an already-authed user never |
| 65 | + // takes that path: they land directly on /login with a live token and would |
| 66 | + // otherwise be shown the sign-in form again, never firing the completion. |
| 67 | + // So here, when BOTH a token exists AND cli_session is present, we fire the |
| 68 | + // SAME completion path (completeCliSession) immediately and show a terminal- |
| 69 | + // return confirmation instead of the sign-in form. Mirrors LoginCallbackPage: |
| 70 | + // same function, best-effort (completeCliSession swallows its own errors), and |
| 71 | + // a failure surfaces a note but does NOT hard-block. |
| 72 | + const [cliApproved, setCliApproved] = useState<null | 'ok' | 'failed'>(null) |
| 73 | + // Ref guard so completeCliSession fires EXACTLY once. React StrictMode (and |
| 74 | + // any fast remount) double-invokes mount effects in dev; the server side is |
| 75 | + // idempotent, but firing one POST keeps the behaviour deterministic and |
| 76 | + // avoids a duplicate device-flow completion. We deliberately do NOT abort the |
| 77 | + // state update on effect cleanup: under StrictMode the first pass's cleanup |
| 78 | + // fires before the async resolves, and gating on a per-pass `cancelled` flag |
| 79 | + // (combined with the fire-once ref preventing the second pass from re-firing) |
| 80 | + // would drop the result entirely and never render the confirmation. A |
| 81 | + // setState after unmount is a no-op in React 19, so firing once and always |
| 82 | + // committing the outcome is correct here. |
| 83 | + const cliFiredRef = useRef(false) |
| 84 | + useEffect(() => { |
| 85 | + const cli = readCliSession() |
| 86 | + // Only the already-authed path runs here. A fresh-login user has no token |
| 87 | + // yet; their cli_session rides through return_to to LoginCallbackPage. |
| 88 | + if (!cli || !getToken() || cliFiredRef.current) return |
| 89 | + cliFiredRef.current = true |
| 90 | + void (async () => { |
| 91 | + const { ok } = await completeCliSession(cli) |
| 92 | + setCliApproved(ok ? 'ok' : 'failed') |
| 93 | + })() |
| 94 | + }, []) |
| 95 | + |
59 | 96 | // sendMagicLink — POST /auth/email/start. Extracted from submitEmail so the |
60 | 97 | // "Resend" affordance in the sent-confirmation state (F4) can re-fire the |
61 | 98 | // exact same request without duplicating the fetch + error handling. |
@@ -130,15 +167,72 @@ export function LoginPage() { |
130 | 167 | navigate(dest, { replace: true }) |
131 | 168 | } catch (e: any) { |
132 | 169 | clearToken() |
133 | | - setErr( |
134 | | - e?.status === 401 |
135 | | - ? 'Token rejected. Mint a fresh PAT or use the claim link from your agent.' |
136 | | - : `Couldn't reach the API: ${e?.message ?? 'unknown error'}`, |
137 | | - ) |
| 170 | + const rejected = 'Token rejected. Mint a fresh PAT or use the claim link from your agent.' |
| 171 | + const unreachable = `Couldn't reach the API: ${e?.message ?? 'unknown error'}` |
| 172 | + setErr(e?.status === 401 ? rejected : unreachable) |
138 | 173 | setBusy(false) |
139 | 174 | } |
140 | 175 | } |
141 | 176 |
|
| 177 | + // D2 — already-authed CLI approval confirmation. The user came from a |
| 178 | + // terminal (they ran `instant login`); don't silently drop them into the |
| 179 | + // dashboard. Show a clear "return to your terminal" screen. On a completion |
| 180 | + // failure we still confirm they're signed in and tell them to retry the CLI |
| 181 | + // (completeCliSession never throws, so this is the only failure surface). |
| 182 | + if (cliApproved !== null) { |
| 183 | + return ( |
| 184 | + <div className="auth-shell"> |
| 185 | + <div className="auth-card" data-testid="cli-approved" style={{ maxWidth: 480 }}> |
| 186 | + <div style={{ marginBottom: 28 }}> |
| 187 | + <Brand /> |
| 188 | + </div> |
| 189 | + {cliApproved === 'ok' ? ( |
| 190 | + <> |
| 191 | + <h1>CLI session approved.</h1> |
| 192 | + <div |
| 193 | + role="status" |
| 194 | + data-testid="cli-approved-ok" |
| 195 | + style={{ |
| 196 | + marginTop: 16, |
| 197 | + padding: '10px 12px', |
| 198 | + borderLeft: '2px solid var(--accent)', |
| 199 | + fontSize: 13, |
| 200 | + fontFamily: 'var(--font-mono)', |
| 201 | + color: 'var(--text)', |
| 202 | + lineHeight: 1.6, |
| 203 | + }} |
| 204 | + > |
| 205 | + ✓ Your CLI session is approved — return to your terminal. You can |
| 206 | + close this tab. |
| 207 | + </div> |
| 208 | + </> |
| 209 | + ) : ( |
| 210 | + <> |
| 211 | + <h1>Couldn't approve the CLI session.</h1> |
| 212 | + <div |
| 213 | + role="alert" |
| 214 | + data-testid="cli-approved-failed" |
| 215 | + style={{ |
| 216 | + marginTop: 16, |
| 217 | + padding: '10px 12px', |
| 218 | + borderLeft: '2px solid var(--rose)', |
| 219 | + fontSize: 13, |
| 220 | + fontFamily: 'var(--font-mono)', |
| 221 | + color: 'var(--text)', |
| 222 | + lineHeight: 1.6, |
| 223 | + }} |
| 224 | + > |
| 225 | + You're signed in, but we couldn't link this browser to your CLI |
| 226 | + session — it may have expired. Re-run <code style={{ color: 'var(--accent)' }}>instant login</code> in |
| 227 | + your terminal to get a fresh link. |
| 228 | + </div> |
| 229 | + </> |
| 230 | + )} |
| 231 | + </div> |
| 232 | + </div> |
| 233 | + ) |
| 234 | + } |
| 235 | + |
142 | 236 | return ( |
143 | 237 | <div className="auth-shell"> |
144 | 238 | <div className="auth-card"> |
|
0 commit comments