Adds a per-project forced provider (github|gitlab|gitea) on top of the per-project API base URL overrides: stored under gitProviders.provider in projects/<projectId>.json, sanitized server-side, and winning over remote-host detection both in useGitProvider and in server repo resolution (parseGitLabRemoteUrl/parseGiteaRemoteUrl accept any host when the provider is forced). The Projects settings page replaces the three always-visible URL fields with a provider selector (auto-detect + the three forges) and one URL override for the active provider. Global provider override fields on the GitHub/GitLab/Gitea settings tabs now render only once an account is connected, and Settings search availability matches that gating. Also fixes the useConfigStore/useDirectoryStore circular-import TDZ in the bundled chunk via the window-registered store handle and defers the directory subscription to a microtask; fixes the Gitea PR merge payload (Do carries the merge-style string enum, not a boolean + MergeMethod); and adds a documented Gitea client live-test harness (scripts/gitea-live-test.ts + client.d.ts).
546 lines
22 KiB
TypeScript
546 lines
22 KiB
TypeScript
import React from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { toast } from '@/components/ui';
|
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
import type { GitHubAuthStatus } from '@/lib/api/types';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { cn } from '@/lib/utils';
|
|
import { openExternalUrl } from '@/lib/url';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { SettingsSection, SettingsGroupTitle } from '@/components/sections/shared/SettingsSection';
|
|
import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput';
|
|
import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput';
|
|
|
|
type GitHubUser = {
|
|
login: string;
|
|
id?: number;
|
|
avatarUrl?: string;
|
|
name?: string;
|
|
email?: string;
|
|
};
|
|
|
|
type DeviceFlowStartResponse = {
|
|
deviceCode: string;
|
|
userCode: string;
|
|
verificationUri: string;
|
|
verificationUriComplete?: string;
|
|
expiresIn: number;
|
|
interval: number;
|
|
scope?: string;
|
|
};
|
|
|
|
type DeviceFlowCompleteResponse =
|
|
| { connected: true; user: GitHubUser; scope?: string }
|
|
| { connected: false; status?: string; error?: string };
|
|
|
|
export const GitHubSettings: React.FC<{ embedded?: boolean }> = ({ embedded = false }) => {
|
|
const { t } = useI18n();
|
|
const { isMobile } = useDeviceInfo();
|
|
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
|
|
const status = useGitHubAuthStore((state) => state.status);
|
|
const isLoading = useGitHubAuthStore((state) => state.isLoading);
|
|
const hasChecked = useGitHubAuthStore((state) => state.hasChecked);
|
|
const refreshStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
|
const setStatus = useGitHubAuthStore((state) => state.setStatus);
|
|
|
|
const openExternal = React.useCallback(async (url: string) => {
|
|
await openExternalUrl(url);
|
|
}, []);
|
|
|
|
const [isBusy, setIsBusy] = React.useState(false);
|
|
const [flow, setFlow] = React.useState<DeviceFlowStartResponse | null>(null);
|
|
const [pollIntervalMs, setPollIntervalMs] = React.useState<number | null>(null);
|
|
const pollTimerRef = React.useRef<number | null>(null);
|
|
|
|
const stopPolling = React.useCallback(() => {
|
|
if (pollTimerRef.current != null) {
|
|
window.clearInterval(pollTimerRef.current);
|
|
pollTimerRef.current = null;
|
|
}
|
|
setPollIntervalMs(null);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
if (!hasChecked) {
|
|
await refreshStatus(runtimeGitHub);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load GitHub auth status:', error);
|
|
}
|
|
})();
|
|
return () => {
|
|
stopPolling();
|
|
};
|
|
}, [hasChecked, refreshStatus, runtimeGitHub, stopPolling]);
|
|
|
|
const startConnect = React.useCallback(async () => {
|
|
setIsBusy(true);
|
|
try {
|
|
const payload = runtimeGitHub
|
|
? await runtimeGitHub.authStart()
|
|
: await (async () => {
|
|
const response = await runtimeFetch('/api/github/auth/start', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
const body = (await response.json().catch(() => null)) as DeviceFlowStartResponse | { error?: string } | null;
|
|
if (!response.ok || !body || !('deviceCode' in body)) {
|
|
throw new Error((body as { error?: string } | null)?.error || response.statusText);
|
|
}
|
|
return body;
|
|
})();
|
|
|
|
setFlow(payload);
|
|
setPollIntervalMs(Math.max(1, payload.interval) * 1000);
|
|
|
|
const url = payload.verificationUriComplete || payload.verificationUri;
|
|
void openExternal(url);
|
|
} catch (error) {
|
|
console.error('Failed to start GitHub connect:', error);
|
|
toast.error(t('settings.github.page.toast.startConnectFailed'));
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}, [openExternal, runtimeGitHub, t]);
|
|
|
|
const pollOnce = React.useCallback(async (deviceCode: string) => {
|
|
if (runtimeGitHub) {
|
|
return runtimeGitHub.authComplete(deviceCode) as Promise<DeviceFlowCompleteResponse>;
|
|
}
|
|
|
|
const response = await runtimeFetch('/api/github/auth/complete', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({ deviceCode }),
|
|
});
|
|
|
|
const payload = (await response.json().catch(() => null)) as DeviceFlowCompleteResponse | { error?: string } | null;
|
|
if (!response.ok || !payload) {
|
|
throw new Error((payload as { error?: string } | null)?.error || response.statusText);
|
|
}
|
|
return payload as DeviceFlowCompleteResponse;
|
|
}, [runtimeGitHub]);
|
|
|
|
React.useEffect(() => {
|
|
if (!flow?.deviceCode || !pollIntervalMs) {
|
|
return;
|
|
}
|
|
if (pollTimerRef.current != null) {
|
|
return;
|
|
}
|
|
|
|
pollTimerRef.current = window.setInterval(() => {
|
|
void (async () => {
|
|
try {
|
|
const result = await pollOnce(flow.deviceCode);
|
|
if (result.connected) {
|
|
toast.success(t('settings.github.page.toast.connected'));
|
|
setFlow(null);
|
|
stopPolling();
|
|
await refreshStatus(runtimeGitHub, { force: true });
|
|
return;
|
|
}
|
|
|
|
if (result.status === 'slow_down') {
|
|
setPollIntervalMs((prev) => (prev ? prev + 5000 : 5000));
|
|
}
|
|
|
|
if (result.status === 'expired_token' || result.status === 'access_denied') {
|
|
toast.error(result.error || t('settings.github.page.toast.authorizationFailed'));
|
|
setFlow(null);
|
|
stopPolling();
|
|
}
|
|
} catch (error) {
|
|
console.warn('GitHub polling failed:', error);
|
|
}
|
|
})();
|
|
}, pollIntervalMs);
|
|
|
|
return () => {
|
|
if (pollTimerRef.current != null) {
|
|
window.clearInterval(pollTimerRef.current);
|
|
pollTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling, t]);
|
|
|
|
const toggleGhCli = React.useCallback(async (disabled: boolean) => {
|
|
setIsBusy(true);
|
|
try {
|
|
if (runtimeGitHub) {
|
|
await runtimeGitHub.authSetGhCliDisabled(disabled);
|
|
} else {
|
|
const response = await runtimeFetch('/api/github/auth/gh-cli', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({ disabled }),
|
|
});
|
|
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
if (!response.ok) throw new Error(body?.error || response.statusText);
|
|
}
|
|
toast.success(disabled ? t('settings.github.page.toast.ghCliDisabled') : t('settings.github.page.toast.ghCliEnabled'));
|
|
await refreshStatus(runtimeGitHub, { force: true });
|
|
} catch (error) {
|
|
console.error('Failed to update gh CLI setting:', error);
|
|
toast.error(t('settings.github.page.toast.ghCliUpdateFailed'));
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}, [refreshStatus, runtimeGitHub, t]);
|
|
|
|
const disconnect = React.useCallback(async () => {
|
|
setIsBusy(true);
|
|
try {
|
|
stopPolling();
|
|
setFlow(null);
|
|
if (runtimeGitHub) {
|
|
await runtimeGitHub.authDisconnect();
|
|
} else {
|
|
const response = await runtimeFetch('/api/github/auth', {
|
|
method: 'DELETE',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(response.statusText);
|
|
}
|
|
}
|
|
toast.success(t('settings.github.page.toast.disconnected'));
|
|
await refreshStatus(runtimeGitHub, { force: true });
|
|
} catch (error) {
|
|
console.error('Failed to disconnect GitHub:', error);
|
|
toast.error(t('settings.github.page.toast.disconnectFailed'));
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}, [refreshStatus, runtimeGitHub, stopPolling, t]);
|
|
|
|
const activateAccount = React.useCallback(async (accountId: string) => {
|
|
if (!accountId) return;
|
|
setIsBusy(true);
|
|
try {
|
|
const payload = runtimeGitHub
|
|
? await runtimeGitHub.authActivate(accountId)
|
|
: await (async () => {
|
|
const response = await runtimeFetch('/api/github/auth/activate', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({ accountId }),
|
|
});
|
|
const body = (await response.json().catch(() => null)) as GitHubAuthStatus | { error?: string } | null;
|
|
if (!response.ok || !body) {
|
|
throw new Error((body as { error?: string } | null)?.error || response.statusText);
|
|
}
|
|
return body as GitHubAuthStatus;
|
|
})();
|
|
|
|
setStatus(payload);
|
|
toast.success(t('settings.github.page.toast.accountSwitched'));
|
|
} catch (error) {
|
|
console.error('Failed to switch GitHub account:', error);
|
|
toast.error(t('settings.github.page.toast.accountSwitchFailed'));
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
}, [runtimeGitHub, setStatus, t]);
|
|
|
|
if (isLoading) {
|
|
return null;
|
|
}
|
|
|
|
const connected = Boolean(status?.connected);
|
|
const user = status?.user;
|
|
const accounts = status?.accounts ?? [];
|
|
const otherAccounts = accounts.filter((account) => !account.current);
|
|
const ghCli = status?.ghCli ?? null;
|
|
const activeAccountSourceLabel = ghCli?.active
|
|
? t('settings.github.page.accountSource.cli')
|
|
: t('settings.github.page.accountSource.oauth');
|
|
|
|
const accountContent = (
|
|
<>
|
|
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
|
{connected ? (
|
|
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
|
|
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
|
|
{user?.avatarUrl ? (
|
|
<img
|
|
src={user.avatarUrl}
|
|
alt={user.login ? t('settings.github.page.avatarAlt.withLogin', { login: user.login }) : t('settings.github.page.avatarAlt.fallback')}
|
|
className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
) : (
|
|
<div className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]" />
|
|
)}
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="typography-ui-label text-foreground">
|
|
{user?.name?.trim() || user?.login || 'GitHub'}
|
|
</div>
|
|
<div className={cn("flex items-center gap-2 typography-meta text-muted-foreground mt-0.5", isMobile ? "flex-wrap" : "truncate")}>
|
|
<Icon name="github-fill" className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="font-mono">{user?.login || t('settings.github.page.label.unknownUser')}</span>
|
|
{user?.email && <span className="opacity-50">•</span>}
|
|
{user?.email && <span>{user.email}</span>}
|
|
<span className="opacity-50">•</span>
|
|
<span>{activeAccountSourceLabel}</span>
|
|
</div>
|
|
{status?.scope && (
|
|
<div className="typography-micro text-muted-foreground/70 mt-0.5">
|
|
{t('settings.github.page.label.scopes', { value: status.scope })}
|
|
</div>
|
|
)}
|
|
{ghCli?.active && (
|
|
<div className="typography-micro text-muted-foreground/70 mt-0.5">
|
|
{t('settings.github.page.ghCli.activeDescription')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{ghCli?.active ? (
|
|
<Button size="sm" variant="outline" onClick={() => toggleGhCli(true)} disabled={isBusy} className={cn(isMobile ? "w-full" : undefined)}>
|
|
{t('settings.github.page.ghCli.actions.disable')}
|
|
</Button>
|
|
) : (
|
|
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
|
|
{t('settings.github.page.actions.disconnect')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between gap-4 px-4 py-4">
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="typography-ui-label text-foreground">{t('settings.github.page.status.notConnected')}</span>
|
|
</div>
|
|
<Button size="sm" variant="default" onClick={startConnect} disabled={isBusy}>
|
|
{t('settings.github.page.actions.connect')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{otherAccounts.length > 0 && (
|
|
<div className="mt-2 border-t border-[var(--surface-subtle)] pt-2 px-2 pb-1">
|
|
<div className="typography-micro text-muted-foreground mb-2 px-1">
|
|
{t('settings.github.page.label.otherAccounts')}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{otherAccounts.map((account) => {
|
|
const accountUser = account.user;
|
|
const sourceLabel = account.source === 'gh-cli'
|
|
? t('settings.github.page.accountSource.cli')
|
|
: t('settings.github.page.accountSource.oauth');
|
|
return (
|
|
<div
|
|
key={account.id}
|
|
className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
{accountUser?.avatarUrl ? (
|
|
<img
|
|
src={accountUser.avatarUrl}
|
|
alt={accountUser.login ? t('settings.github.page.avatarAlt.withLogin', { login: accountUser.login }) : t('settings.github.page.avatarAlt.fallback')}
|
|
className="h-6 w-6 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
) : (
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
|
|
<Icon name="github-fill" className="h-3 w-3 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex flex-col">
|
|
<span className="typography-ui-label text-foreground truncate">
|
|
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
|
|
</span>
|
|
{accountUser?.login && (
|
|
<span className="typography-micro text-muted-foreground truncate">
|
|
<span className="font-mono">{accountUser.login}</span>
|
|
<span className="mx-1 opacity-50">·</span>
|
|
<span>{sourceLabel}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button size="sm"
|
|
variant="ghost"
|
|
onClick={() => activateAccount(account.id)}
|
|
disabled={isBusy}
|
|
>
|
|
{t('settings.github.page.actions.switchTo')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
|
|
{connected && (
|
|
<div className="mt-2 px-2 pb-2">
|
|
<Button size="sm"
|
|
variant="outline"
|
|
onClick={startConnect}
|
|
disabled={isBusy}
|
|
className={cn(isMobile ? 'w-full' : undefined)}
|
|
>
|
|
{t('settings.github.page.actions.addAccount')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{flow && (
|
|
<div className="mt-4 rounded-lg bg-[var(--surface-elevated)]/70 p-4 border border-[var(--interactive-border)]">
|
|
<div className="space-y-1">
|
|
<SettingsGroupTitle>{t('settings.github.page.flow.title')}</SettingsGroupTitle>
|
|
<p className="typography-meta text-muted-foreground">
|
|
{t('settings.github.page.flow.description')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 mt-4">
|
|
<div className="font-mono text-xl tracking-widest text-foreground bg-[var(--surface-muted)] px-3 py-1.5 rounded-md border border-[var(--interactive-border)]">{flow.userCode}</div>
|
|
<Button size="sm" asChild>
|
|
<a
|
|
href={flow.verificationUriComplete || flow.verificationUri}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{t('settings.github.page.actions.openGithub')}
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<span className="typography-micro text-muted-foreground animate-pulse">
|
|
{t('settings.github.page.flow.waiting')}
|
|
</span>
|
|
<Button size="sm" variant="ghost" disabled={isBusy} onClick={() => {
|
|
stopPolling();
|
|
setFlow(null);
|
|
}}>
|
|
{t('settings.common.actions.cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className={cn('flex flex-col gap-4', embedded ? 'border-t border-[var(--surface-subtle)] pt-4' : 'pt-4')}>
|
|
{connected ? (
|
|
<>
|
|
<ProviderApiBaseUrlInput provider="github" />
|
|
<ProviderDetectUrlsInput provider="github" />
|
|
</>
|
|
) : (
|
|
<p className="typography-meta text-muted-foreground">
|
|
{t('settings.gitProviders.overridesLocked.description', { provider: t('settings.git.tabs.github') })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const ghCliContent = ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) ? (
|
|
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden">
|
|
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
|
|
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
|
|
{ghCli.user?.avatarUrl ? (
|
|
<img
|
|
src={ghCli.user.avatarUrl}
|
|
alt={ghCli.user.login ? t('settings.github.page.avatarAlt.withLogin', { login: ghCli.user.login }) : t('settings.github.page.avatarAlt.fallback')}
|
|
className="h-10 w-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
) : (
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
|
|
<Icon name="github-fill" className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
{!ghCli.disabled && ghCli.user && (
|
|
<div className="typography-ui-label text-foreground truncate">
|
|
{ghCli.user.name?.trim() || ghCli.user.login || 'GitHub'}
|
|
</div>
|
|
)}
|
|
{!ghCli.disabled && ghCli.user?.login && (
|
|
<div className={cn("flex items-center gap-2 typography-meta text-muted-foreground mt-0.5", isMobile ? "flex-wrap" : "truncate")}>
|
|
<Icon name="github-fill" className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="font-mono">{ghCli.user.login}</span>
|
|
{ghCli.user.email && <span className="opacity-50">•</span>}
|
|
{ghCli.user.email && <span>{ghCli.user.email}</span>}
|
|
</div>
|
|
)}
|
|
<div className={cn("typography-meta text-muted-foreground", ghCli.disabled ? "opacity-60" : undefined)}>
|
|
{ghCli.disabled
|
|
? t('settings.github.page.ghCli.disabledDescription')
|
|
: t('settings.github.page.ghCli.fallbackDescription')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => toggleGhCli(!ghCli.disabled)}
|
|
disabled={isBusy}
|
|
className={cn(isMobile ? "w-full" : undefined)}
|
|
>
|
|
{ghCli.disabled
|
|
? t('settings.github.page.ghCli.actions.enable')
|
|
: t('settings.github.page.ghCli.actions.disable')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
if (embedded) {
|
|
return (
|
|
<>
|
|
<div data-settings-item="git.github-account" className="p-4">
|
|
{accountContent}
|
|
</div>
|
|
{ghCliContent && (
|
|
<div className="border-t border-[var(--surface-subtle)] px-4 py-4">
|
|
{ghCliContent}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<SettingsSection
|
|
title={t('settings.github.page.oauth.title')}
|
|
divider={false}
|
|
settingsItem="git.github-account"
|
|
info={t('settings.github.page.tooltip.connectAccount')}
|
|
>
|
|
{accountContent}
|
|
</SettingsSection>
|
|
|
|
{ghCliContent && (
|
|
<SettingsSection title={t('settings.github.page.ghCli.title')}>
|
|
{ghCliContent}
|
|
</SettingsSection>
|
|
)}
|
|
</>
|
|
);
|
|
};
|