Files
openchamber/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx
T
bot-hermes 45437a4221 feat(web,ui): per-project forced git provider and settings gating
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).
2026-08-17 09:57:54 +00:00

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>
)}
</>
);
};