@nastechai/agent 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ExternalLink, X, Check } from "lucide-react";
|
|
3
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
4
|
+
import { CopyButton } from "@nastechai/ui/ui/components/command-block";
|
|
5
|
+
import { Spinner } from "@nastechai/ui/ui/components/spinner";
|
|
6
|
+
import { H2 } from "@nastechai/ui/ui/components/typography/h2";
|
|
7
|
+
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
|
8
|
+
import { Input } from "@nastechai/ui/ui/components/input";
|
|
9
|
+
import { useI18n } from "@/i18n";
|
|
10
|
+
import { cn, themedBody } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
provider: OAuthProvider;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
onSuccess: (msg: string) => void;
|
|
16
|
+
onError: (msg: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Phase =
|
|
20
|
+
| "idle"
|
|
21
|
+
| "starting"
|
|
22
|
+
| "awaiting_user"
|
|
23
|
+
| "submitting"
|
|
24
|
+
| "polling"
|
|
25
|
+
| "approved"
|
|
26
|
+
| "error";
|
|
27
|
+
|
|
28
|
+
export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
|
|
29
|
+
const [phase, setPhase] = useState<Phase>("starting");
|
|
30
|
+
const [start, setStart] = useState<OAuthStartResponse | null>(null);
|
|
31
|
+
const [pkceCode, setPkceCode] = useState("");
|
|
32
|
+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
33
|
+
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
|
34
|
+
const isMounted = useRef(true);
|
|
35
|
+
const pollTimer = useRef<number | null>(null);
|
|
36
|
+
const { t } = useI18n();
|
|
37
|
+
|
|
38
|
+
// Initiate flow on mount
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
isMounted.current = true;
|
|
41
|
+
api
|
|
42
|
+
.startOAuthLogin(provider.id)
|
|
43
|
+
.then((resp) => {
|
|
44
|
+
if (!isMounted.current) return;
|
|
45
|
+
setStart(resp);
|
|
46
|
+
setSecondsLeft(resp.expires_in);
|
|
47
|
+
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
|
48
|
+
if (resp.flow === "pkce") {
|
|
49
|
+
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
|
50
|
+
} else {
|
|
51
|
+
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
.catch((e) => {
|
|
55
|
+
if (!isMounted.current) return;
|
|
56
|
+
setPhase("error");
|
|
57
|
+
setErrorMsg(`Failed to start login: ${e}`);
|
|
58
|
+
});
|
|
59
|
+
return () => {
|
|
60
|
+
isMounted.current = false;
|
|
61
|
+
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
62
|
+
};
|
|
63
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// Tick the countdown
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (secondsLeft === null) return;
|
|
69
|
+
if (phase === "approved" || phase === "error") return;
|
|
70
|
+
const tick = window.setInterval(() => {
|
|
71
|
+
if (!isMounted.current) return;
|
|
72
|
+
setSecondsLeft((s) => {
|
|
73
|
+
if (s !== null && s <= 1) {
|
|
74
|
+
setPhase("error");
|
|
75
|
+
setErrorMsg(t.oauth.sessionExpired);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
return s !== null && s > 0 ? s - 1 : 0;
|
|
79
|
+
});
|
|
80
|
+
}, 1000);
|
|
81
|
+
return () => window.clearInterval(tick);
|
|
82
|
+
}, [secondsLeft, phase, t]);
|
|
83
|
+
|
|
84
|
+
// Device-code: poll backend every 2s
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!start || start.flow !== "device_code" || phase !== "polling") return;
|
|
87
|
+
const sid = start.session_id;
|
|
88
|
+
pollTimer.current = window.setInterval(async () => {
|
|
89
|
+
try {
|
|
90
|
+
const resp = await api.pollOAuthSession(provider.id, sid);
|
|
91
|
+
if (!isMounted.current) return;
|
|
92
|
+
if (resp.status === "approved") {
|
|
93
|
+
setPhase("approved");
|
|
94
|
+
if (pollTimer.current !== null)
|
|
95
|
+
window.clearInterval(pollTimer.current);
|
|
96
|
+
onSuccess(`${provider.name} connected`);
|
|
97
|
+
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
|
98
|
+
} else if (resp.status !== "pending") {
|
|
99
|
+
setPhase("error");
|
|
100
|
+
setErrorMsg(resp.error_message || `Login ${resp.status}`);
|
|
101
|
+
if (pollTimer.current !== null)
|
|
102
|
+
window.clearInterval(pollTimer.current);
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (!isMounted.current) return;
|
|
106
|
+
setPhase("error");
|
|
107
|
+
setErrorMsg(`Polling failed: ${e}`);
|
|
108
|
+
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
109
|
+
}
|
|
110
|
+
}, 2000);
|
|
111
|
+
return () => {
|
|
112
|
+
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
|
113
|
+
};
|
|
114
|
+
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);
|
|
115
|
+
|
|
116
|
+
const handleSubmitPkceCode = async () => {
|
|
117
|
+
if (!start || start.flow !== "pkce") return;
|
|
118
|
+
if (!pkceCode.trim()) return;
|
|
119
|
+
setPhase("submitting");
|
|
120
|
+
setErrorMsg(null);
|
|
121
|
+
try {
|
|
122
|
+
const resp = await api.submitOAuthCode(
|
|
123
|
+
provider.id,
|
|
124
|
+
start.session_id,
|
|
125
|
+
pkceCode.trim(),
|
|
126
|
+
);
|
|
127
|
+
if (!isMounted.current) return;
|
|
128
|
+
if (resp.ok && resp.status === "approved") {
|
|
129
|
+
setPhase("approved");
|
|
130
|
+
onSuccess(`${provider.name} connected`);
|
|
131
|
+
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
|
132
|
+
} else {
|
|
133
|
+
setPhase("error");
|
|
134
|
+
setErrorMsg(resp.message || "Token exchange failed");
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if (!isMounted.current) return;
|
|
138
|
+
setPhase("error");
|
|
139
|
+
setErrorMsg(`Submit failed: ${e}`);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const handleClose = async () => {
|
|
144
|
+
if (start && phase !== "approved" && phase !== "error") {
|
|
145
|
+
try {
|
|
146
|
+
await api.cancelOAuthSession(start.session_id);
|
|
147
|
+
} catch {
|
|
148
|
+
// ignore
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
onClose();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleBackdrop = (e: React.MouseEvent) => {
|
|
155
|
+
if (e.target === e.currentTarget) handleClose();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const fmtTime = (s: number | null) => {
|
|
159
|
+
if (s === null) return "";
|
|
160
|
+
const m = Math.floor(s / 60);
|
|
161
|
+
const r = s % 60;
|
|
162
|
+
return `${m}:${String(r).padStart(2, "0")}`;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
168
|
+
onClick={handleBackdrop}
|
|
169
|
+
role="dialog"
|
|
170
|
+
aria-modal="true"
|
|
171
|
+
aria-labelledby="oauth-modal-title"
|
|
172
|
+
>
|
|
173
|
+
<div className={cn(themedBody, "relative w-full max-w-md border border-border bg-card shadow-2xl")}>
|
|
174
|
+
<Button
|
|
175
|
+
ghost
|
|
176
|
+
size="icon"
|
|
177
|
+
onClick={handleClose}
|
|
178
|
+
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
179
|
+
aria-label={t.common.close}
|
|
180
|
+
>
|
|
181
|
+
<X />
|
|
182
|
+
</Button>
|
|
183
|
+
<div className="p-6 flex flex-col gap-4">
|
|
184
|
+
<div>
|
|
185
|
+
<H2
|
|
186
|
+
id="oauth-modal-title"
|
|
187
|
+
variant="sm"
|
|
188
|
+
mondwest
|
|
189
|
+
className="tracking-wider uppercase"
|
|
190
|
+
>
|
|
191
|
+
{t.oauth.connect} {provider.name}
|
|
192
|
+
</H2>
|
|
193
|
+
{secondsLeft !== null &&
|
|
194
|
+
phase !== "approved" &&
|
|
195
|
+
phase !== "error" && (
|
|
196
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
197
|
+
{t.oauth.sessionExpires.replace(
|
|
198
|
+
"{time}",
|
|
199
|
+
fmtTime(secondsLeft),
|
|
200
|
+
)}
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{phase === "starting" && (
|
|
206
|
+
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
|
207
|
+
<Spinner />
|
|
208
|
+
{t.oauth.initiatingLogin}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{start?.flow === "pkce" && phase === "awaiting_user" && (
|
|
213
|
+
<>
|
|
214
|
+
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
|
|
215
|
+
<li>{t.oauth.pkceStep1}</li>
|
|
216
|
+
<li>{t.oauth.pkceStep2}</li>
|
|
217
|
+
<li>{t.oauth.pkceStep3}</li>
|
|
218
|
+
</ol>
|
|
219
|
+
<div className="flex flex-col gap-2">
|
|
220
|
+
<Input
|
|
221
|
+
value={pkceCode}
|
|
222
|
+
onChange={(e) => setPkceCode(e.target.value)}
|
|
223
|
+
placeholder={t.oauth.pasteCode}
|
|
224
|
+
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
|
|
225
|
+
autoFocus
|
|
226
|
+
/>
|
|
227
|
+
<div className="flex items-center gap-2 justify-between">
|
|
228
|
+
<a
|
|
229
|
+
href={
|
|
230
|
+
(start as Extract<OAuthStartResponse, { flow: "pkce" }>)
|
|
231
|
+
.auth_url
|
|
232
|
+
}
|
|
233
|
+
target="_blank"
|
|
234
|
+
rel="noopener noreferrer"
|
|
235
|
+
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
|
236
|
+
>
|
|
237
|
+
<ExternalLink className="h-3 w-3" />
|
|
238
|
+
{t.oauth.reOpenAuth}
|
|
239
|
+
</a>
|
|
240
|
+
<Button
|
|
241
|
+
onClick={handleSubmitPkceCode}
|
|
242
|
+
disabled={!pkceCode.trim()}
|
|
243
|
+
>
|
|
244
|
+
{t.oauth.submitCode}
|
|
245
|
+
</Button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{phase === "submitting" && (
|
|
252
|
+
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
|
253
|
+
<Spinner />
|
|
254
|
+
{t.oauth.exchangingCode}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{start?.flow === "device_code" && phase === "polling" && (
|
|
259
|
+
<>
|
|
260
|
+
<p className="text-sm text-muted-foreground">
|
|
261
|
+
{t.oauth.enterCodePrompt}
|
|
262
|
+
</p>
|
|
263
|
+
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
|
|
264
|
+
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
|
|
265
|
+
{
|
|
266
|
+
(
|
|
267
|
+
start as Extract<
|
|
268
|
+
OAuthStartResponse,
|
|
269
|
+
{ flow: "device_code" }
|
|
270
|
+
>
|
|
271
|
+
).user_code
|
|
272
|
+
}
|
|
273
|
+
</code>
|
|
274
|
+
<CopyButton
|
|
275
|
+
text={
|
|
276
|
+
(
|
|
277
|
+
start as Extract<
|
|
278
|
+
OAuthStartResponse,
|
|
279
|
+
{ flow: "device_code" }
|
|
280
|
+
>
|
|
281
|
+
).user_code
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
<a
|
|
286
|
+
href={
|
|
287
|
+
(
|
|
288
|
+
start as Extract<
|
|
289
|
+
OAuthStartResponse,
|
|
290
|
+
{ flow: "device_code" }
|
|
291
|
+
>
|
|
292
|
+
).verification_url
|
|
293
|
+
}
|
|
294
|
+
target="_blank"
|
|
295
|
+
rel="noopener noreferrer"
|
|
296
|
+
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
|
297
|
+
>
|
|
298
|
+
<ExternalLink className="h-3 w-3" />
|
|
299
|
+
{t.oauth.reOpenVerification}
|
|
300
|
+
</a>
|
|
301
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
|
|
302
|
+
<Spinner className="text-xs" />
|
|
303
|
+
{t.oauth.waitingAuth}
|
|
304
|
+
</div>
|
|
305
|
+
</>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{phase === "approved" && (
|
|
309
|
+
<div className="flex items-center gap-3 py-6 text-sm text-success">
|
|
310
|
+
<Check className="h-5 w-5" />
|
|
311
|
+
{t.oauth.connectedClosing}
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
{phase === "error" && (
|
|
316
|
+
<>
|
|
317
|
+
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
|
318
|
+
{errorMsg || t.oauth.loginFailed}
|
|
319
|
+
</div>
|
|
320
|
+
<div className="flex justify-end gap-2">
|
|
321
|
+
<Button outlined onClick={handleClose}>
|
|
322
|
+
{t.common.close}
|
|
323
|
+
</Button>
|
|
324
|
+
<Button
|
|
325
|
+
onClick={() => {
|
|
326
|
+
if (start?.session_id) {
|
|
327
|
+
api.cancelOAuthSession(start.session_id).catch(() => {});
|
|
328
|
+
}
|
|
329
|
+
setErrorMsg(null);
|
|
330
|
+
setStart(null);
|
|
331
|
+
setPkceCode("");
|
|
332
|
+
setPhase("starting");
|
|
333
|
+
api
|
|
334
|
+
.startOAuthLogin(provider.id)
|
|
335
|
+
.then((resp) => {
|
|
336
|
+
if (!isMounted.current) return;
|
|
337
|
+
setStart(resp);
|
|
338
|
+
setSecondsLeft(resp.expires_in);
|
|
339
|
+
setPhase(
|
|
340
|
+
resp.flow === "device_code"
|
|
341
|
+
? "polling"
|
|
342
|
+
: "awaiting_user",
|
|
343
|
+
);
|
|
344
|
+
if (resp.flow === "pkce") {
|
|
345
|
+
window.open(
|
|
346
|
+
resp.auth_url,
|
|
347
|
+
"_blank",
|
|
348
|
+
"noopener,noreferrer",
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
window.open(
|
|
352
|
+
resp.verification_url,
|
|
353
|
+
"_blank",
|
|
354
|
+
"noopener,noreferrer",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
.catch((e) => {
|
|
359
|
+
if (!isMounted.current) return;
|
|
360
|
+
setPhase("error");
|
|
361
|
+
setErrorMsg(`${t.common.retry} failed: ${e}`);
|
|
362
|
+
});
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
{t.common.retry}
|
|
366
|
+
</Button>
|
|
367
|
+
</div>
|
|
368
|
+
</>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ShieldCheck,
|
|
4
|
+
ShieldOff,
|
|
5
|
+
ExternalLink,
|
|
6
|
+
RefreshCw,
|
|
7
|
+
Terminal,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { api, type OAuthProvider } from "@/lib/api";
|
|
10
|
+
import { Button } from "@nastechai/ui/ui/components/button";
|
|
11
|
+
import { CopyButton } from "@nastechai/ui/ui/components/command-block";
|
|
12
|
+
import { Spinner } from "@nastechai/ui/ui/components/spinner";
|
|
13
|
+
import {
|
|
14
|
+
Card,
|
|
15
|
+
CardContent,
|
|
16
|
+
CardDescription,
|
|
17
|
+
CardHeader,
|
|
18
|
+
CardTitle,
|
|
19
|
+
} from "@nastechai/ui/ui/components/card";
|
|
20
|
+
import { Badge } from "@nastechai/ui/ui/components/badge";
|
|
21
|
+
import { ConfirmDialog } from "@nastechai/ui/ui/components/confirm-dialog";
|
|
22
|
+
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
|
23
|
+
import { useI18n } from "@/i18n";
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
onError?: (msg: string) => void;
|
|
27
|
+
onSuccess?: (msg: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatExpiresAt(
|
|
31
|
+
expiresAt: string | null | undefined,
|
|
32
|
+
expiresInTemplate: string,
|
|
33
|
+
): string | null {
|
|
34
|
+
if (!expiresAt) return null;
|
|
35
|
+
try {
|
|
36
|
+
const dt = new Date(expiresAt);
|
|
37
|
+
if (Number.isNaN(dt.getTime())) return null;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const diff = dt.getTime() - now;
|
|
40
|
+
if (diff < 0) return "expired";
|
|
41
|
+
const mins = Math.floor(diff / 60_000);
|
|
42
|
+
if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`);
|
|
43
|
+
const hours = Math.floor(mins / 60);
|
|
44
|
+
if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`);
|
|
45
|
+
const days = Math.floor(hours / 24);
|
|
46
|
+
return expiresInTemplate.replace("{time}", `${days}d`);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|
53
|
+
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
|
|
54
|
+
const [loading, setLoading] = useState(true);
|
|
55
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
56
|
+
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
|
57
|
+
const [disconnectTarget, setDisconnectTarget] =
|
|
58
|
+
useState<OAuthProvider | null>(null);
|
|
59
|
+
const { t } = useI18n();
|
|
60
|
+
|
|
61
|
+
const onErrorRef = useRef(onError);
|
|
62
|
+
onErrorRef.current = onError;
|
|
63
|
+
|
|
64
|
+
const refresh = useCallback(() => {
|
|
65
|
+
setLoading(true);
|
|
66
|
+
api
|
|
67
|
+
.getOAuthProviders()
|
|
68
|
+
.then((resp) => setProviders(resp.providers))
|
|
69
|
+
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
|
|
70
|
+
.finally(() => setLoading(false));
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
refresh();
|
|
75
|
+
}, [refresh]);
|
|
76
|
+
|
|
77
|
+
const handleDisconnect = async (provider: OAuthProvider) => {
|
|
78
|
+
setBusyId(provider.id);
|
|
79
|
+
setDisconnectTarget(null);
|
|
80
|
+
try {
|
|
81
|
+
await api.disconnectOAuthProvider(provider.id);
|
|
82
|
+
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
|
|
83
|
+
refresh();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
onError?.(`${t.oauth.disconnect} failed: ${e}`);
|
|
86
|
+
} finally {
|
|
87
|
+
setBusyId(null);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const connectedCount =
|
|
92
|
+
providers?.filter((p) => p.status.logged_in).length ?? 0;
|
|
93
|
+
const totalCount = providers?.length ?? 0;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Card>
|
|
97
|
+
<CardHeader>
|
|
98
|
+
<div className="flex items-center justify-between">
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
|
101
|
+
<CardTitle className="text-base">
|
|
102
|
+
{t.oauth.providerLogins}
|
|
103
|
+
</CardTitle>
|
|
104
|
+
</div>
|
|
105
|
+
<Button
|
|
106
|
+
ghost
|
|
107
|
+
size="icon"
|
|
108
|
+
className="text-muted-foreground hover:text-foreground"
|
|
109
|
+
onClick={refresh}
|
|
110
|
+
disabled={loading}
|
|
111
|
+
aria-label={t.common.refresh}
|
|
112
|
+
>
|
|
113
|
+
{loading ? <Spinner /> : <RefreshCw />}
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
<CardDescription>
|
|
117
|
+
{t.oauth.description
|
|
118
|
+
.replace("{connected}", String(connectedCount))
|
|
119
|
+
.replace("{total}", String(totalCount))}
|
|
120
|
+
</CardDescription>
|
|
121
|
+
</CardHeader>
|
|
122
|
+
<CardContent>
|
|
123
|
+
{loading && providers === null && (
|
|
124
|
+
<div className="flex items-center justify-center py-8">
|
|
125
|
+
<Spinner className="text-xl text-primary" />
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
{providers && providers.length === 0 && (
|
|
129
|
+
<p className="text-sm text-muted-foreground text-center py-8">
|
|
130
|
+
{t.oauth.noProviders}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
<div className="flex flex-col divide-y divide-border">
|
|
134
|
+
{providers?.map((p) => {
|
|
135
|
+
const expiresLabel = formatExpiresAt(
|
|
136
|
+
p.status.expires_at,
|
|
137
|
+
t.oauth.expiresIn,
|
|
138
|
+
);
|
|
139
|
+
const isBusy = busyId === p.id;
|
|
140
|
+
return (
|
|
141
|
+
<div
|
|
142
|
+
key={p.id}
|
|
143
|
+
className="flex items-center justify-between gap-4 py-3"
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-start gap-3 min-w-0 flex-1">
|
|
146
|
+
{p.status.logged_in ? (
|
|
147
|
+
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
|
|
148
|
+
) : (
|
|
149
|
+
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
|
150
|
+
)}
|
|
151
|
+
<div className="flex flex-col min-w-0 gap-0.5">
|
|
152
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
153
|
+
<span className="font-medium text-sm">{p.name}</span>
|
|
154
|
+
<Badge
|
|
155
|
+
tone="outline"
|
|
156
|
+
className="text-xs tracking-wide"
|
|
157
|
+
>
|
|
158
|
+
{t.oauth.flowLabels[p.flow]}
|
|
159
|
+
</Badge>
|
|
160
|
+
{p.status.logged_in && (
|
|
161
|
+
<Badge tone="success" className="text-xs">
|
|
162
|
+
{t.oauth.connected}
|
|
163
|
+
</Badge>
|
|
164
|
+
)}
|
|
165
|
+
{expiresLabel === "expired" && (
|
|
166
|
+
<Badge tone="destructive" className="text-xs">
|
|
167
|
+
{t.oauth.expired}
|
|
168
|
+
</Badge>
|
|
169
|
+
)}
|
|
170
|
+
{expiresLabel && expiresLabel !== "expired" && (
|
|
171
|
+
<Badge tone="outline" className="text-xs">
|
|
172
|
+
{expiresLabel}
|
|
173
|
+
</Badge>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
{p.status.logged_in && p.status.token_preview && (
|
|
177
|
+
<span className="truncate text-xs font-mono-ui text-text-secondary">
|
|
178
|
+
<span className="text-text-tertiary">token </span>
|
|
179
|
+
{p.status.token_preview}
|
|
180
|
+
{p.status.source_label && (
|
|
181
|
+
<span className="text-text-tertiary">
|
|
182
|
+
{" "}
|
|
183
|
+
· {p.status.source_label}
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</span>
|
|
187
|
+
)}
|
|
188
|
+
{!p.status.logged_in && (
|
|
189
|
+
<>
|
|
190
|
+
<span className="text-xs text-text-secondary">
|
|
191
|
+
{t.oauth.notConnected.split("{command}")[0].trimEnd()}
|
|
192
|
+
{t.oauth.notConnected.split("{command}")[1] ?? ""}
|
|
193
|
+
</span>
|
|
194
|
+
|
|
195
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
196
|
+
<code className="font-courier truncate text-xs opacity-60">
|
|
197
|
+
{p.cli_command}
|
|
198
|
+
</code>
|
|
199
|
+
|
|
200
|
+
<CopyButton
|
|
201
|
+
text={p.cli_command}
|
|
202
|
+
label={t.oauth.cli}
|
|
203
|
+
copiedLabel={t.oauth.copied}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
{p.status.error && (
|
|
209
|
+
<span className="text-xs text-destructive">
|
|
210
|
+
{p.status.error}
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
217
|
+
{p.docs_url && (
|
|
218
|
+
<a
|
|
219
|
+
href={p.docs_url}
|
|
220
|
+
target="_blank"
|
|
221
|
+
rel="noopener noreferrer"
|
|
222
|
+
className="inline-flex"
|
|
223
|
+
title={`Open ${p.name} docs`}
|
|
224
|
+
>
|
|
225
|
+
<Button ghost size="icon">
|
|
226
|
+
<ExternalLink />
|
|
227
|
+
</Button>
|
|
228
|
+
</a>
|
|
229
|
+
)}
|
|
230
|
+
{!p.status.logged_in && p.flow !== "external" && (
|
|
231
|
+
<Button
|
|
232
|
+
size="sm"
|
|
233
|
+
className="uppercase"
|
|
234
|
+
onClick={() => setLoginFor(p)}
|
|
235
|
+
>
|
|
236
|
+
{t.oauth.login}
|
|
237
|
+
</Button>
|
|
238
|
+
)}
|
|
239
|
+
{p.status.logged_in && p.flow !== "external" && (
|
|
240
|
+
<Button
|
|
241
|
+
size="sm"
|
|
242
|
+
outlined
|
|
243
|
+
className="uppercase"
|
|
244
|
+
onClick={() => setDisconnectTarget(p)}
|
|
245
|
+
disabled={isBusy}
|
|
246
|
+
prefix={isBusy ? <Spinner /> : undefined}
|
|
247
|
+
>
|
|
248
|
+
{t.oauth.disconnect}
|
|
249
|
+
</Button>
|
|
250
|
+
)}
|
|
251
|
+
{p.status.logged_in && p.flow === "external" && (
|
|
252
|
+
<span className="text-xs text-text-tertiary italic px-2">
|
|
253
|
+
<Terminal className="h-3 w-3 inline mr-0.5" />
|
|
254
|
+
{t.oauth.managedExternally}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</div>
|
|
262
|
+
</CardContent>
|
|
263
|
+
{loginFor && (
|
|
264
|
+
<OAuthLoginModal
|
|
265
|
+
provider={loginFor}
|
|
266
|
+
onClose={() => {
|
|
267
|
+
setLoginFor(null);
|
|
268
|
+
refresh();
|
|
269
|
+
}}
|
|
270
|
+
onSuccess={(msg) => onSuccess?.(msg)}
|
|
271
|
+
onError={(msg) => onError?.(msg)}
|
|
272
|
+
/>
|
|
273
|
+
)}
|
|
274
|
+
<ConfirmDialog
|
|
275
|
+
open={disconnectTarget !== null}
|
|
276
|
+
onCancel={() => setDisconnectTarget(null)}
|
|
277
|
+
onConfirm={() => {
|
|
278
|
+
if (disconnectTarget) void handleDisconnect(disconnectTarget);
|
|
279
|
+
}}
|
|
280
|
+
title={`${t.oauth.disconnect} ${disconnectTarget?.name ?? ""}?`}
|
|
281
|
+
description={`This will remove the stored OAuth tokens for ${disconnectTarget?.name ?? "this provider"}. You will need to re-authenticate to use it again.`}
|
|
282
|
+
destructive
|
|
283
|
+
confirmLabel={t.oauth.disconnect}
|
|
284
|
+
/>
|
|
285
|
+
</Card>
|
|
286
|
+
);
|
|
287
|
+
}
|