mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-26 01:01:40 +00:00
feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)
Add a lightweight i18n system to the web dashboard with English (default) and Chinese language support. A language switcher with flag icons is placed in the header bar, allowing users to toggle between languages. The choice persists to localStorage. Implementation: - src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook - LanguageSwitcher component shows the *other* language's flag as the toggle - I18nProvider wraps the app in main.tsx - All 8 pages + OAuth components updated to use t() translation calls - Zero new dependencies — pure React context + localStorage
This commit is contained in:
parent
19199cd38d
commit
a2ea237db2
19 changed files with 1715 additions and 977 deletions
27
web/src/components/LanguageSwitcher.tsx
Normal file
27
web/src/components/LanguageSwitcher.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
/**
|
||||
* Compact language toggle — shows a clickable flag that switches between
|
||||
* English and Chinese. Persists choice to localStorage.
|
||||
*/
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
|
||||
const toggle = () => setLocale(locale === "en" ? "zh" : "en");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded"
|
||||
title={t.language.switchTo}
|
||||
aria-label={t.language.switchTo}
|
||||
>
|
||||
{/* Show the *other* language's flag as the clickable target */}
|
||||
<span className="text-base leading-none">{locale === "en" ? "🇨🇳" : "🇬🇧"}</span>
|
||||
<span className="hidden sm:inline font-display tracking-wide uppercase text-[0.65rem]">
|
||||
{locale === "en" ? "中文" : "EN"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,29 +3,7 @@ import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
|
|||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
/**
|
||||
* OAuthLoginModal — drives the in-browser OAuth flow for a single provider.
|
||||
*
|
||||
* Two variants share the same modal shell:
|
||||
*
|
||||
* - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes,
|
||||
* pastes the resulting code back. We POST it to /submit which exchanges
|
||||
* the (code + verifier) pair for tokens server-side.
|
||||
*
|
||||
* - Device code (Nous, OpenAI Codex): we display the verification URL
|
||||
* and short user code; the backend polls the provider's token endpoint
|
||||
* in a background thread; we poll /poll/{session_id} every 2s for status.
|
||||
*
|
||||
* Edge cases handled:
|
||||
* - Popup blocker (we use plain anchor href + open in new tab; no popup
|
||||
* window.open which is more likely to be blocked).
|
||||
* - Modal dismissal mid-flight cancels the server-side session via DELETE.
|
||||
* - Code expiry surfaces as a clear error state with retry button.
|
||||
* - Polling continues to work if the user backgrounds the tab (setInterval
|
||||
* keeps firing in modern browsers; we guard against polls firing after
|
||||
* component unmount via an isMounted ref).
|
||||
*/
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface Props {
|
||||
provider: OAuthProvider;
|
||||
|
|
@ -45,6 +23,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
const [codeCopied, setCodeCopied] = useState(false);
|
||||
const isMounted = useRef(true);
|
||||
const pollTimer = useRef<number | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Initiate flow on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -57,10 +36,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
if (resp.flow === "pkce") {
|
||||
// Auto-open the auth URL in a new tab
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
// Device-code: open the verification URL automatically
|
||||
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
})
|
||||
|
|
@ -73,7 +50,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
isMounted.current = false;
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
};
|
||||
// We only want to start the flow once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
|
@ -85,16 +61,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
if (!isMounted.current) return;
|
||||
setSecondsLeft((s) => {
|
||||
if (s !== null && s <= 1) {
|
||||
// Session expired — transition to error state
|
||||
setPhase("error");
|
||||
setErrorMsg("Session expired. Click Retry to start a new login.");
|
||||
setErrorMsg(t.oauth.sessionExpired);
|
||||
return 0;
|
||||
}
|
||||
return s !== null && s > 0 ? s - 1 : 0;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(tick);
|
||||
}, [secondsLeft, phase]);
|
||||
}, [secondsLeft, phase, t]);
|
||||
|
||||
// Device-code: poll backend every 2s
|
||||
useEffect(() => {
|
||||
|
|
@ -115,7 +90,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
}
|
||||
} catch (e) {
|
||||
// 404 = session expired/cleaned up; treat as error
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Polling failed: ${e}`);
|
||||
|
|
@ -151,12 +125,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
// Cancel server session if still in flight
|
||||
if (start && phase !== "approved" && phase !== "error") {
|
||||
try {
|
||||
await api.cancelOAuthSession(start.session_id);
|
||||
} catch {
|
||||
// ignore — server-side TTL will clean it up anyway
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
|
|
@ -172,7 +145,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
}
|
||||
};
|
||||
|
||||
// Backdrop click closes
|
||||
const handleBackdrop = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
};
|
||||
|
|
@ -197,18 +169,18 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 id="oauth-modal-title" className="font-display text-base tracking-wider uppercase">
|
||||
Connect {provider.name}
|
||||
{t.oauth.connect} {provider.name}
|
||||
</h2>
|
||||
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Session expires in {fmtTime(secondsLeft)}
|
||||
{t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -217,7 +189,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
{phase === "starting" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Initiating login flow…
|
||||
{t.oauth.initiatingLogin}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -225,18 +197,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
{start?.flow === "pkce" && phase === "awaiting_user" && (
|
||||
<>
|
||||
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
|
||||
<li>
|
||||
A new tab opened to <code className="text-foreground">claude.ai</code>. Sign in
|
||||
and click <strong className="text-foreground">Authorize</strong>.
|
||||
</li>
|
||||
<li>Copy the <strong className="text-foreground">authorization code</strong> shown after authorizing.</li>
|
||||
<li>Paste it below and submit.</li>
|
||||
<li>{t.oauth.pkceStep1}</li>
|
||||
<li>{t.oauth.pkceStep2}</li>
|
||||
<li>{t.oauth.pkceStep3}</li>
|
||||
</ol>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={pkceCode}
|
||||
onChange={(e) => setPkceCode(e.target.value)}
|
||||
placeholder="Paste authorization code (with #state suffix is fine)"
|
||||
placeholder={t.oauth.pasteCode}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
|
||||
autoFocus
|
||||
/>
|
||||
|
|
@ -248,10 +217,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Re-open auth page
|
||||
{t.oauth.reOpenAuth}
|
||||
</a>
|
||||
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
|
||||
Submit code
|
||||
{t.oauth.submitCode}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -262,7 +231,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
{phase === "submitting" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Exchanging code for tokens…
|
||||
{t.oauth.exchangingCode}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -270,7 +239,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
{start?.flow === "device_code" && phase === "polling" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A new tab opened. Enter this code if prompted:
|
||||
{t.oauth.enterCodePrompt}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
|
||||
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
|
||||
|
|
@ -296,11 +265,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Re-open verification page
|
||||
{t.oauth.reOpenVerification}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Waiting for you to authorize in the browser…
|
||||
{t.oauth.waitingAuth}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -309,7 +278,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
{phase === "approved" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-success">
|
||||
<Check className="h-5 w-5" />
|
||||
Connected! Closing…
|
||||
{t.oauth.connectedClosing}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -317,16 +286,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
{phase === "error" && (
|
||||
<>
|
||||
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{errorMsg || "Login failed."}
|
||||
{errorMsg || t.oauth.loginFailed}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
{t.common.close}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Cancel the old session before starting a new one
|
||||
if (start?.session_id) {
|
||||
api.cancelOAuthSession(start.session_id).catch(() => {});
|
||||
}
|
||||
|
|
@ -334,8 +302,6 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
setStart(null);
|
||||
setPkceCode("");
|
||||
setPhase("starting");
|
||||
// Re-trigger the start effect by remounting (caller should re-key us)
|
||||
// Simpler: just kick off a new start manually
|
||||
api.startOAuthLogin(provider.id).then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
|
|
@ -349,11 +315,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
}).catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Retry failed: ${e}`);
|
||||
setErrorMsg(`${t.common.retry} failed: ${e}`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
{t.common.retry}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -5,29 +5,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
||||
|
||||
/**
|
||||
* OAuthProvidersCard — surfaces every OAuth-capable LLM provider with its
|
||||
* current connection status, a truncated token preview when connected, and
|
||||
* action buttons (Copy CLI command for setup, Disconnect for cleanup).
|
||||
*
|
||||
* Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI
|
||||
* command. Phase 2 will add in-browser PKCE / device-code flows so users
|
||||
* never need to drop to a terminal.
|
||||
*/
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
interface Props {
|
||||
onError?: (msg: string) => void;
|
||||
onSuccess?: (msg: string) => void;
|
||||
}
|
||||
|
||||
const FLOW_LABELS: Record<OAuthProvider["flow"], string> = {
|
||||
pkce: "Browser login (PKCE)",
|
||||
device_code: "Device code",
|
||||
external: "External CLI",
|
||||
};
|
||||
|
||||
function formatExpiresAt(expiresAt: string | null | undefined): string | null {
|
||||
function formatExpiresAt(expiresAt: string | null | undefined, expiresInTemplate: string): string | null {
|
||||
if (!expiresAt) return null;
|
||||
try {
|
||||
const dt = new Date(expiresAt);
|
||||
|
|
@ -36,11 +21,11 @@ function formatExpiresAt(expiresAt: string | null | undefined): string | null {
|
|||
const diff = dt.getTime() - now;
|
||||
if (diff < 0) return "expired";
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 60) return `expires in ${mins}m`;
|
||||
if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`);
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `expires in ${hours}h`;
|
||||
if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`);
|
||||
const days = Math.floor(hours / 24);
|
||||
return `expires in ${days}d`;
|
||||
return expiresInTemplate.replace("{time}", `${days}d`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -51,10 +36,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
// Provider that the login modal is currently open for. null = modal closed.
|
||||
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Use refs for callbacks to avoid re-creating refresh() when parent re-renders
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
|
|
@ -83,16 +67,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
};
|
||||
|
||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||
if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) {
|
||||
if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
|
||||
return;
|
||||
}
|
||||
setBusyId(provider.id);
|
||||
try {
|
||||
await api.disconnectOAuthProvider(provider.id);
|
||||
onSuccess?.(`${provider.name} disconnected`);
|
||||
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
onError?.(`Disconnect failed: ${e}`);
|
||||
onError?.(`${t.oauth.disconnect} failed: ${e}`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
|
|
@ -107,7 +91,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Provider Logins (OAuth)</CardTitle>
|
||||
<CardTitle className="text-base">{t.oauth.providerLogins}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -117,12 +101,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
className="text-xs"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{connectedCount} of {totalCount} OAuth providers connected. Login flows currently
|
||||
run via the CLI; click <em>Copy command</em> and paste into a terminal to set up.
|
||||
{t.oauth.description.replace("{connected}", String(connectedCount)).replace("{total}", String(totalCount))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -133,12 +116,12 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
)}
|
||||
{providers && providers.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No OAuth-capable providers detected.
|
||||
{t.oauth.noProviders}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{providers?.map((p) => {
|
||||
const expiresLabel = formatExpiresAt(p.status.expires_at);
|
||||
const expiresLabel = formatExpiresAt(p.status.expires_at, t.oauth.expiresIn);
|
||||
const isBusy = busyId === p.id;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -156,16 +139,16 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{p.name}</span>
|
||||
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
|
||||
{FLOW_LABELS[p.flow]}
|
||||
{t.oauth.flowLabels[p.flow]}
|
||||
</Badge>
|
||||
{p.status.logged_in && (
|
||||
<Badge variant="success" className="text-[11px]">
|
||||
Connected
|
||||
{t.oauth.connected}
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel === "expired" && (
|
||||
<Badge variant="destructive" className="text-[11px]">
|
||||
Expired
|
||||
{t.oauth.expired}
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel && expiresLabel !== "expired" && (
|
||||
|
|
@ -187,11 +170,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
)}
|
||||
{!p.status.logged_in && (
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
Not connected. Run{" "}
|
||||
<code className="text-foreground bg-secondary/40 px-1">
|
||||
{t.oauth.notConnected.split("{command}")[0]}
|
||||
<code className="text-foreground bg-secondary/40 px-1 rounded">
|
||||
{p.cli_command}
|
||||
</code>{" "}
|
||||
in a terminal.
|
||||
</code>
|
||||
{t.oauth.notConnected.split("{command}")[1]}
|
||||
</span>
|
||||
)}
|
||||
{p.status.error && (
|
||||
|
|
@ -222,10 +205,9 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
size="sm"
|
||||
onClick={() => setLoginFor(p)}
|
||||
className="text-xs h-7"
|
||||
title={`Start ${p.flow === "pkce" ? "browser" : "device code"} login`}
|
||||
>
|
||||
<LogIn className="h-3 w-3 mr-1" />
|
||||
Login
|
||||
{t.oauth.login}
|
||||
</Button>
|
||||
)}
|
||||
{!p.status.logged_in && (
|
||||
|
|
@ -234,14 +216,14 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
size="sm"
|
||||
onClick={() => handleCopy(p)}
|
||||
className="text-xs h-7"
|
||||
title="Copy CLI command (for external / fallback)"
|
||||
title={t.oauth.copyCliCommand}
|
||||
>
|
||||
{copiedId === p.id ? (
|
||||
<>Copied ✓</>
|
||||
<>{t.oauth.copied}</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
CLI
|
||||
{t.oauth.cli}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -259,13 +241,13 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
) : (
|
||||
<LogOut className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Disconnect
|
||||
{t.oauth.disconnect}
|
||||
</Button>
|
||||
)}
|
||||
{p.status.logged_in && p.flow === "external" && (
|
||||
<span className="text-[11px] text-muted-foreground italic px-2">
|
||||
<Terminal className="h-3 w-3 inline mr-0.5" />
|
||||
Managed externally
|
||||
{t.oauth.managedExternally}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -279,7 +261,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
provider={loginFor}
|
||||
onClose={() => {
|
||||
setLoginFor(null);
|
||||
refresh(); // always refresh on close so token preview updates after login
|
||||
refresh();
|
||||
}}
|
||||
onSuccess={(msg) => onSuccess?.(msg)}
|
||||
onError={(msg) => onError?.(msg)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue