1import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts";
2import { useState } from "react";
3
4import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
5import { Button } from "../ui/button";
6import {
7 Dialog,
8 DialogDescription,
9 DialogFooter,
10 DialogHeader,
11 DialogPanel,
12 DialogPopup,
13 DialogTitle,
14} from "../ui/dialog";
15import { Input } from "../ui/input";
16import { SecretInput } from "./SecretInput";
17import { Label } from "../ui/label";
18
19/**
20 * Stable per hub and readable in settings.json. Dots and dashes in the host
21 * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else
22 * (a port's colon, a path) is folded to a dash.
23 */
24function sourceIdFromUrl(url: string): UsageLimitSourceId {
25 let host = url;
26 try {
27 host = new URL(url).host;
28 } catch {
29 // Keep the raw text; the server reports the bad URL on its row.
30 }
31 const slug = host
32 .toLowerCase()
33 .replace(/[^a-z0-9.-]+/g, "-")
34 .replace(/^-+|-+$/g, "");
35 return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`);
36}
37
38/**
39 * Adds a CLIProxyAPI hub from provider settings on one environment. The
40 * management key is sent once and kept in that server's secret store;
41 * settings only ever carry a redaction marker for it afterwards.
42 */
43export function AddUsageLimitSourceDialog({
44 open,
45 onOpenChange,
46 environmentId,
47 environmentLabel,
48}: {
49 readonly open: boolean;
50 readonly onOpenChange: (open: boolean) => void;
51 readonly environmentId: EnvironmentId;
52 readonly environmentLabel: string;
53}) {
54 const updateSettings = useUpdateEnvironmentSettings(environmentId);
55 const [label, setLabel] = useState("");
56 const [url, setUrl] = useState("");
57 const [managementKey, setManagementKey] = useState("");
58 const trimmedUrl = url.trim();
59 const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0;
60
61 const reset = () => {
62 setLabel("");
63 setUrl("");
64 setManagementKey("");
65 };
66
67 const save = () => {
68 if (!canSave) return;
69 const id = sourceIdFromUrl(trimmedUrl);
70 // The patch names only this entry; the server merges it into its map.
71 updateSettings({
72 usageLimitSources: {
73 [id]: {
74 kind: "cliproxy",
75 ...(label.trim() ? { label: label.trim() } : {}),
76 url: trimmedUrl,
77 managementKey: managementKey.trim(),
78 enabled: true,
79 },
80 },
81 });
82 reset();
83 onOpenChange(false);
84 };
85
86 return (
87 <Dialog
88 open={open}
89 onOpenChange={(next) => {
90 if (!next) reset();
91 onOpenChange(next);
92 }}
93 >
94 <DialogPopup className="max-w-md">
95 <DialogHeader>
96 <DialogTitle>Add a CLIProxyAPI hub</DialogTitle>
97 <DialogDescription>
98 Show the quota of every account the hub pools, next to the providers on{" "}
99 {environmentLabel}. The key stays on that server.
100 </DialogDescription>
101 </DialogHeader>
102 <DialogPanel>
103 <form
104 className="grid gap-4"
105 onSubmit={(event) => {
106 event.preventDefault();
107 save();
108 }}
109 >
110 <div className="grid gap-1.5">
111 <Label htmlFor="usage-source-url">Hub URL</Label>
112 <Input
113 id="usage-source-url"
114 placeholder="https://hub.example.ts.net:8318"
115 value={url}
116 onChange={(event) => setUrl(event.target.value)}
117 autoFocus
118 />
119 </div>
120 <div className="grid gap-1.5">
121 <Label htmlFor="usage-source-key">Management key</Label>
122 <SecretInput
123 id="usage-source-key"
124 value={managementKey}
125 onChange={(event) => setManagementKey(event.target.value)}
126 />
127 </div>
128 <div className="grid gap-1.5">
129 <Label htmlFor="usage-source-label">Label (optional)</Label>
130 <Input
131 id="usage-source-label"
132 placeholder="Defaults to the hub's host name"
133 value={label}
134 onChange={(event) => setLabel(event.target.value)}
135 />
136 </div>
137 </form>
138 </DialogPanel>
139 <DialogFooter variant="bare">
140 <Button
141 variant="outline"
142 onClick={() => {
143 reset();
144 onOpenChange(false);
145 }}
146 >
147 Cancel
148 </Button>
149 <Button onClick={save} disabled={!canSave}>
150 Add hub
151 </Button>
152 </DialogFooter>
153 </DialogPopup>
154 </Dialog>
155 );
156}