File size: 7,742 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import Button from "@/shared/components/Button";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import {
  runCodexDeviceFlow,
  CodexDeviceFlowError,
  type CodexUserCode,
} from "@/lib/oauth/codexDeviceFlow";

type Status = "validating" | "ready" | "starting" | "awaiting" | "saving" | "success" | "error";

/**
 * Drives the public Codex device flow entirely in the visitor's browser
 * (auth.openai.com blocks datacenter IPs but allows CORS), then posts the final
 * tokens back to the ticket-gated completion endpoint for persistence.
 */
export default function CodexConnectClient({ token }: { token: string }) {
  const [status, setStatus] = useState<Status>("validating");
  const [error, setError] = useState<string | null>(null);
  const [userCode, setUserCode] = useState<CodexUserCode | null>(null);
  const { copied, copy } = useCopyToClipboard();
  const abortRef = useRef<AbortController | null>(null);

  // Validate the link on load so we can show "ready" vs "expired" before starting.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await fetch(`/api/codex/connect/${token}`);
        if (cancelled) return;
        if (res.ok) {
          setStatus("ready");
        } else {
          const data = await res.json().catch(() => ({}));
          setError(data?.error || "This link is invalid or expired.");
          setStatus("error");
        }
      } catch {
        if (!cancelled) {
          setError("Could not reach the server to validate this link.");
          setStatus("error");
        }
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [token]);

  // Abort any in-flight device flow if the visitor leaves.
  useEffect(() => () => abortRef.current?.abort(), []);

  const start = useCallback(async () => {
    setError(null);
    setUserCode(null);
    setStatus("starting");

    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const tokens = await runCodexDeviceFlow({
        signal: controller.signal,
        onUserCode: (uc) => {
          setUserCode(uc);
          setStatus("awaiting");
        },
      });

      setStatus("saving");
      const res = await fetch(`/api/codex/connect/${token}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(tokens),
      });
      const data = await res.json().catch(() => ({}));
      if (res.ok && data?.success) {
        setStatus("success");
      } else {
        setError(data?.error || "Could not save the connection. The link may have expired.");
        setStatus("error");
      }
    } catch (err) {
      if (err instanceof CodexDeviceFlowError) {
        setError(
          err.code === "device_disabled"
            ? "Device code login is disabled for this OpenAI account. Enable it in ChatGPT security settings (or ask your workspace admin)."
            : err.code === "timeout"
              ? "Authorization timed out. Click Start again to retry."
              : err.code === "aborted"
                ? "Authentication was cancelled."
                : err.message
        );
      } else {
        setError("Unexpected error during authentication. Please try again.");
      }
      setStatus("error");
    }
  }, [token]);

  return (
    <div className="min-h-screen flex items-center justify-center bg-bg-base px-4 py-10">
      <div className="w-full max-w-md rounded-2xl border border-border bg-bg-subtle p-8 shadow-sm">
        <div className="mb-6 text-center">
          <div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
            <span className="material-symbols-outlined text-[28px]">key</span>
          </div>
          <h1 className="text-lg font-semibold text-text-main">Connect OpenAI Codex</h1>
          <p className="mt-1 text-sm text-text-muted">
            Authorize a ChatGPT account to finish setting up this connection.
          </p>
        </div>

        {status === "validating" && (
          <p className="text-center text-sm text-text-muted">Validating link…</p>
        )}

        {status === "ready" && (
          <div className="text-center">
            <p className="mb-4 text-sm text-text-muted">
              Click below to generate a one-time code, then sign in to OpenAI.
            </p>
            <Button onClick={start} icon="login" className="w-full">
              Start
            </Button>
          </div>
        )}

        {status === "starting" && (
          <p className="text-center text-sm text-text-muted">Requesting code from OpenAI…</p>
        )}

        {status === "awaiting" && userCode && (
          <div className="space-y-4">
            <p className="text-sm text-text-muted">
              1. Open the OpenAI verification page and 2. enter this code. This page updates
              automatically once you authorize.
            </p>

            <div className="rounded-lg border border-border bg-bg-base p-3">
              <p className="mb-1 text-xs text-text-muted">Your code</p>
              <div className="flex items-center justify-between gap-2">
                <code className="text-lg font-semibold tracking-widest text-text-main">
                  {userCode.userCode}
                </code>
                <Button
                  size="sm"
                  variant="secondary"
                  icon="content_copy"
                  onClick={() => copy(userCode.userCode, "code")}
                >
                  {copied === "code" ? "Copied" : "Copy"}
                </Button>
              </div>
            </div>

            <div className="flex gap-2">
              <Button
                className="flex-1"
                icon="open_in_new"
                onClick={() => window.open(userCode.verificationUri, "_blank", "noopener")}
              >
                Open verification page
              </Button>
              <Button
                variant="secondary"
                icon="link"
                onClick={() => copy(userCode.verificationUri, "url")}
              >
                {copied === "url" ? "Copied" : "Copy URL"}
              </Button>
            </div>

            <p className="text-center text-xs text-text-muted">Waiting for authorization…</p>
          </div>
        )}

        {status === "saving" && (
          <p className="text-center text-sm text-text-muted">Saving connection…</p>
        )}

        {status === "success" && (
          <div className="text-center">
            <div className="mb-3 inline-flex h-12 w-12 items-center justify-center rounded-full bg-green-500/10 text-green-500">
              <span className="material-symbols-outlined text-[26px]">check_circle</span>
            </div>
            <p className="font-medium text-text-main">Connected!</p>
            <p className="mt-1 text-sm text-text-muted">
              The Codex account was registered. You can close this tab.
            </p>
          </div>
        )}

        {status === "error" && (
          <div className="text-center">
            <div className="mb-3 inline-flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10 text-red-500">
              <span className="material-symbols-outlined text-[26px]">error</span>
            </div>
            <p className="mb-4 text-sm text-text-muted">{error}</p>
            <Button variant="secondary" icon="refresh" onClick={start} className="w-full">
              Try again
            </Button>
          </div>
        )}
      </div>
    </div>
  );
}