1import type { EnvironmentId } from "@t3tools/contracts";
2import { ExternalLinkIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients";
7import { serverEnvironment } from "../../state/server";
8import { useAtomCommand } from "../../state/use-atom-command";
9import { Button, InlineButton } from "../ui/button";
10import { Input } from "../ui/input";
11import { SecretInput } from "./SecretInput";
12import { Label } from "../ui/label";
13
14const DEFAULT_HOST = "github.com";
15
16/** Where to create a token for a host: GitHub's own page, or the same path on an Enterprise host. */
17function newTokenUrl(host: string): string {
18 return `https://${host}/settings/personal-access-tokens/new`;
19}
20
21/**
22 * A GitHub token for one host, kept in the server's secret store. It is used before
23 * `GH_TOKEN` and the `gh` login, so it also works on a server without `gh` installed.
24 * Write-only: the saved token is never shown, only whether one is set.
25 */
26export function GitHubTokenSettings({
27 environmentId,
28 onSaved,
29}: {
30 readonly environmentId: EnvironmentId;
31 readonly onSaved: () => void;
32}) {
33 const { tokens } = useEnvironmentSettings(environmentId, readGitHubSettings);
34 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
35 label: "save GitHub token",
36 });
37 const [host, setHost] = useState(DEFAULT_HOST);
38 const [draft, setDraft] = useState("");
39 const [saving, setSaving] = useState(false);
40 const normalizedHost = host.trim().toLowerCase();
41 const isSaved = (tokens[normalizedHost] ?? "").length > 0;
42 const savedHosts = Object.entries(tokens)
43 .filter(([, value]) => value.length > 0)
44 .map(([saved]) => saved);
45
46 const save = async (target: string, token: string) => {
47 setSaving(true);
48 try {
49 const result = await updateSettings({
50 environmentId,
51 // The map travels whole: every other saved token as its redaction marker, which keeps it.
52 input: {
53 patch: { sourceControlHosts: { github: { tokens: { ...tokens, [target]: token } } } },
54 },
55 });
56 if (result._tag === "Success") {
57 setDraft("");
58 onSaved();
59 }
60 } finally {
61 setSaving(false);
62 }
63 };
64
65 return (
66 <form
67 className="grid gap-3"
68 onSubmit={(event) => {
69 event.preventDefault();
70 if (normalizedHost && draft.trim()) void save(normalizedHost, draft.trim());
71 }}
72 >
73 {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */}
74 <fieldset disabled={saving} className="contents">
75 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
76 A token saved here is used before{" "}
77 <code className="rounded bg-muted px-1 py-px text-2xs">GH_TOKEN</code> and the{" "}
78 <code className="rounded bg-muted px-1 py-px text-2xs">gh</code> login, so GitHub works
79 without the GitHub CLI. Give it read and write access to pull requests and contents.{" "}
80 <InlineButton
81 render={
82 <a
83 href={newTokenUrl(normalizedHost || DEFAULT_HOST)}
84 target="_blank"
85 rel="noreferrer noopener"
86 />
87 }
88 >
89 Create a token
90 <ExternalLinkIcon aria-hidden className="size-3" />
91 </InlineButton>
92 </p>
93 <div className="grid gap-3 sm:grid-cols-[12rem_1fr]">
94 <div className="grid gap-1.5">
95 <Label htmlFor={`github-token-host-${environmentId}`}>Host</Label>
96 <Input
97 id={`github-token-host-${environmentId}`}
98 autoComplete="off"
99 size="sm"
100 placeholder={DEFAULT_HOST}
101 value={host}
102 onChange={(event) => setHost(event.target.value)}
103 />
104 </div>
105 <div className="grid gap-1.5">
106 <Label htmlFor={`github-token-${environmentId}`}>Token</Label>
107 <SecretInput
108 id={`github-token-${environmentId}`}
109 size="sm"
110 configured={isSaved}
111 value={draft}
112 onChange={(event) => setDraft(event.target.value)}
113 />
114 </div>
115 </div>
116 <div className="flex items-center justify-between gap-3">
117 <p className="text-xs text-muted-foreground">
118 {savedHosts.length === 0
119 ? "No token saved; the server uses GH_TOKEN or the gh login."
120 : `Saved for ${savedHosts.join(", ")}.`}
121 </p>
122 <div className="flex shrink-0 gap-2">
123 {isSaved ? (
124 <Button
125 size="xs"
126 variant="outline"
127 disabled={saving}
128 onClick={() => void save(normalizedHost, "")}
129 >
130 Remove
131 </Button>
132 ) : null}
133 <Button type="submit" size="xs" disabled={!normalizedHost || !draft.trim() || saving}>
134 Save
135 </Button>
136 </div>
137 </div>
138 </fieldset>
139 </form>
140 );
141}