feat(web,ui): per-provider git forge API base URL and detection URLs

Configure a default API base URL per git provider (github/gitlab/gitea)
in settings.json gitProviders, with GitHub Enterprise support (Octokit
baseUrl + device-flow web origin derived from the API base), and replace
the client-side custom-domains list with server-persisted detection URL
chips (SSH/HTTPS forms normalized to hosts). The configured API base host
auto-counts as a detection host. Settings round-trip through the existing
/api/config/settings sanitizer; the UI store hydrates from server settings
with a one-time localStorage migration.
This commit is contained in:
2026-08-17 09:55:11 +00:00
parent a87b3fd228
commit 0fc857959e
55 changed files with 1685 additions and 272 deletions
@@ -11,7 +11,8 @@ 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 { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput';
import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput';
import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput';
type GitHubUser = {
login: string;
@@ -446,7 +447,10 @@ export const GitHubSettings: React.FC = () => {
</div>
)}
<CustomDomainsInput provider="github" />
<div className="flex flex-col gap-4 pt-4">
<ProviderApiBaseUrlInput provider="github" />
<ProviderDetectUrlsInput provider="github" />
</div>
</SettingsSection>
@@ -11,7 +11,9 @@ import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput';
import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput';
import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput';
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
const getBaseUrlHost = (baseUrl?: string | null): string => {
if (!baseUrl) return '';
@@ -34,7 +36,11 @@ export const GitLabSettings: React.FC = () => {
const [isBusy, setIsBusy] = React.useState(false);
const [accessToken, setAccessToken] = React.useState('');
const [baseUrl, setBaseUrl] = React.useState('');
// Prefill the connect form with the server-side default API base URL when the
// user has not typed one yet; the per-account base URL still wins on connect.
const [baseUrl, setBaseUrl] = React.useState(
useGitProviderDomainsStore((state) => state.apiBaseUrls.gitlab),
);
React.useEffect(() => {
(async () => {
@@ -290,7 +296,10 @@ export const GitLabSettings: React.FC = () => {
)}
</div>
<CustomDomainsInput provider="gitlab" />
<div className="flex flex-col gap-4 pt-4">
<ProviderApiBaseUrlInput provider="gitlab" />
<ProviderDetectUrlsInput provider="gitlab" />
</div>
</SettingsSection>
);
};
@@ -11,7 +11,9 @@ import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { CustomDomainsInput } from '@/components/sections/shared/CustomDomainsInput';
import { ProviderApiBaseUrlInput } from '@/components/sections/shared/ProviderApiBaseUrlInput';
import { ProviderDetectUrlsInput } from '@/components/sections/shared/ProviderDetectUrlsInput';
import { useGitProviderDomainsStore } from '@/stores/useGitProviderDomainsStore';
const getBaseUrlHost = (baseUrl?: string | null): string => {
if (!baseUrl) return '';
@@ -34,7 +36,11 @@ export const GiteaSettings: React.FC = () => {
const [isBusy, setIsBusy] = React.useState(false);
const [accessToken, setAccessToken] = React.useState('');
const [baseUrl, setBaseUrl] = React.useState('');
// Prefill the connect form with the server-side default API base URL when the
// user has not typed one yet; the per-account base URL still wins on connect.
const [baseUrl, setBaseUrl] = React.useState(
useGitProviderDomainsStore((state) => state.apiBaseUrls.gitea),
);
React.useEffect(() => {
(async () => {
@@ -300,7 +306,10 @@ export const GiteaSettings: React.FC = () => {
)}
</div>
<CustomDomainsInput provider="gitea" />
<div className="flex flex-col gap-4 pt-4">
<ProviderApiBaseUrlInput provider="gitea" />
<ProviderDetectUrlsInput provider="gitea" />
</div>
</SettingsSection>
);
};
@@ -1,50 +0,0 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n';
import { useGitProviderDomainsStore, type GitProviderName } from '@/stores/useGitProviderDomainsStore';
/**
* Comma-separated custom-domain input for a git provider. Commits (normalizes,
* dedupes, persists) on blur or Enter; the field reflects the persisted,
* normalized list joined by ', '.
*/
export const CustomDomainsInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => {
const { t } = useI18n();
const domains = useGitProviderDomainsStore((state) => state.domains[provider]);
const setDomains = useGitProviderDomainsStore((state) => state.setDomains);
const [value, setValue] = React.useState(domains.join(', '));
React.useEffect(() => {
setValue(domains.join(', '));
}, [domains]);
const commit = React.useCallback(() => {
setDomains(provider, value.split(',').map((entry) => entry.trim()).filter(Boolean));
}, [provider, setDomains, value]);
return (
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor={`${provider}-custom-domains`} className="typography-settings-field-label text-foreground">
{t(`settings.${provider}.page.customDomains.label`)}
</label>
<Input
id={`${provider}-custom-domains`}
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
}}
placeholder={t(`settings.${provider}.page.customDomains.placeholder`)}
className="h-9 max-w-[24rem]"
/>
<span className="typography-micro text-muted-foreground">
{t(`settings.${provider}.page.customDomains.description`)}
</span>
</div>
);
};
@@ -0,0 +1,98 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { reportSettingsSaveState } from '@/lib/persistence';
import { SettingsStackedField } from '@/components/sections/shared/SettingsSection';
import {
useGitProviderDomainsStore,
type GitProviderName,
} from '@/stores/useGitProviderDomainsStore';
/**
* Persist the full `gitProviders` settings object. The server replaces the
* whole `gitProviders` key on PUT, so every provider must be sent together —
* sending a single provider alone would wipe the others.
*/
// eslint-disable-next-line react-refresh/only-export-components
export const saveGitProvidersConfig = async (): Promise<void> => {
const { domains, apiBaseUrls } = useGitProviderDomainsStore.getState();
const payload = {
gitProviders: {
github: { apiBaseUrl: apiBaseUrls.github, detectUrls: domains.github },
gitlab: { apiBaseUrl: apiBaseUrls.gitlab, detectUrls: domains.gitlab },
gitea: { apiBaseUrl: apiBaseUrls.gitea, detectUrls: domains.gitea },
},
};
reportSettingsSaveState('saving');
try {
const response = await runtimeFetch('/api/config/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(response.statusText);
}
reportSettingsSaveState('saved');
} catch (error) {
console.warn('Failed to persist git provider settings:', error);
reportSettingsSaveState('error');
}
};
/**
* Server-side default API base URL for a git provider's API calls. A per-account
* base URL still wins once an account is connected. Commits (updates the store
* cache optimistically and persists the full gitProviders object) on blur or
* Enter; the settings round-trip re-hydrates the store afterwards.
*/
export const ProviderApiBaseUrlInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => {
const { t } = useI18n();
const storedValue = useGitProviderDomainsStore((state) => state.apiBaseUrls[provider]);
const setApiBaseUrl = useGitProviderDomainsStore((state) => state.setApiBaseUrl);
const [draft, setDraft] = React.useState(storedValue);
React.useEffect(() => {
setDraft(storedValue);
}, [storedValue]);
const commit = React.useCallback(() => {
const next = draft.trim();
if (next !== storedValue) {
setApiBaseUrl(provider, next);
void saveGitProvidersConfig();
} else {
// Blur with no real change: drop incidental whitespace from the draft.
setDraft(storedValue);
}
}, [draft, provider, setApiBaseUrl, storedValue]);
return (
<SettingsStackedField
label={t(`settings.${provider}.page.apiBaseUrl.label`)}
description={t(`settings.${provider}.page.apiBaseUrl.description`)}
descriptionPlacement="after"
settingsItem={`git.${provider}-api-base-url`}
>
<Input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
}}
placeholder={t(`settings.${provider}.page.apiBaseUrl.placeholder`)}
aria-label={t(`settings.${provider}.page.apiBaseUrl.label`)}
className="h-9"
/>
</SettingsStackedField>
);
};
@@ -0,0 +1,118 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { useI18n } from '@/lib/i18n';
import { SettingsStackedField } from '@/components/sections/shared/SettingsSection';
import {
normalizeProviderDomain,
useGitProviderDomainsStore,
type GitProviderName,
} from '@/stores/useGitProviderDomainsStore';
import { saveGitProvidersConfig } from './ProviderApiBaseUrlInput';
/**
* Detection URLs for a git provider: an SSH or HTTPS URL typed into the field
* becomes a chip (displayed as its bare hostname) on Enter or comma, and the X
* on a chip removes it. The hosts feed provider autodetection of repo remotes.
* Each change updates the store cache optimistically and persists the full
* gitProviders object; the settings round-trip re-hydrates the store.
*/
export const ProviderDetectUrlsInput: React.FC<{ provider: GitProviderName }> = ({ provider }) => {
const { t } = useI18n();
const domains = useGitProviderDomainsStore((state) => state.domains[provider]);
const setDomains = useGitProviderDomainsStore((state) => state.setDomains);
const [draft, setDraft] = React.useState('');
const [invalid, setInvalid] = React.useState(false);
const commitDraft = React.useCallback(() => {
const raw = draft.trim();
if (!raw) {
setDraft('');
setInvalid(false);
return;
}
const next = [...domains];
let added = false;
for (const part of raw.split(',')) {
const host = normalizeProviderDomain(part);
if (host && !next.includes(host)) {
next.push(host);
added = true;
}
}
if (added) {
setDomains(provider, next);
setDraft('');
setInvalid(false);
void saveGitProvidersConfig();
} else {
// Unparseable input: keep the draft so the user can fix it.
setInvalid(true);
}
}, [draft, domains, provider, setDomains]);
const removeChip = React.useCallback((host: string) => {
setDomains(provider, domains.filter((entry) => entry !== host));
void saveGitProvidersConfig();
}, [domains, provider, setDomains]);
return (
<SettingsStackedField
label={t(`settings.${provider}.page.detectUrls.label`)}
description={t(`settings.${provider}.page.detectUrls.description`)}
descriptionPlacement="after"
settingsItem={`git.${provider}-detect-urls`}
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{domains.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{domains.map((host) => (
<span
key={host}
className="inline-flex h-6 items-center gap-0.5 rounded-md border border-border/60 bg-[var(--surface-elevated)] pl-2 pr-1"
>
<span className="typography-micro font-mono text-foreground">{host}</span>
<Button
type="button"
variant="ghost"
size="xs"
aria-label={t('settings.gitProviders.detectUrls.remove', { host })}
title={t('settings.gitProviders.detectUrls.remove', { host })}
onClick={() => removeChip(host)}
className="h-4 w-4 p-0 text-muted-foreground hover:text-[var(--status-error)]"
>
<Icon name="close" className="size-3" />
</Button>
</span>
))}
</div>
) : null}
<Input
type="text"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
setInvalid(false);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
commitDraft();
}
}}
onBlur={commitDraft}
placeholder={t(`settings.${provider}.page.detectUrls.placeholder`)}
aria-label={t('settings.gitProviders.detectUrls.add')}
aria-invalid={invalid || undefined}
className="h-9"
/>
{invalid ? (
<p className="typography-micro text-[var(--status-error)]">
{t('settings.gitProviders.detectUrls.invalid')}
</p>
) : null}
</div>
</SettingsStackedField>
);
};