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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user