@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.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. 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
+ }