Expose OAuth and account providers in onboarding
The provider step was filtered to API_KEY only, which hid GitHub Copilot, Google Antigravity, and GPT account from the founding-CEO runtime picker even though they are first-class providers in the catalog. Show every non-cloud provider, render an "Access token" field in place of the API-key field for OAuth and account auths, and surface a button that opens the provider's official sign-in page via the new openExternalUrl IPC (validated to http(s) only). OnboardingService.resolveProvider now accepts any non-cloud auth type. Cloud providers still route through Settings since their multi-field config does not fit the linear onboarding step.
This commit is contained in:
1 parent
2c3760ca11
commit
e7eb829b48
6 files changed
+69
-23
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { ipcMain, type BrowserWindow } from "electron";
|
||||
import { ipcMain, shell, type BrowserWindow } from "electron";
|
||||
import type { DatabaseConnection } from "../database/connection";
|
||||
import { IPC_CHANNELS } from "../../shared/ipc";
|
||||
import { fail, ok, type DesktopResult } from "../../shared/result";
|
||||
@@ -86,6 +86,18 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
return fail("provider_sign_in_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.openExternalUrl, async (_event, url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return fail("external_url_blocked", "Only http(s) URLs may be opened externally.");
|
||||
}
|
||||
await shell.openExternal(parsed.toString());
|
||||
return ok(null);
|
||||
} catch (error) {
|
||||
return fail("external_url_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.createProviderResponse, async (_event, input) => {
|
||||
try {
|
||||
await providers.createResponse(input);
|
||||
|
||||
@@ -85,8 +85,8 @@ export class OnboardingService {
|
||||
private resolveProvider(providerKey: string) {
|
||||
const provider = PROVIDER_CATALOG.find((entry) => entry.key === providerKey);
|
||||
if (!provider) throw new Error(`Provider ${providerKey} is unavailable.`);
|
||||
if (provider.authType !== PROVIDER_AUTH_TYPE.API_KEY) {
|
||||
throw new Error("Onboarding requires an API key provider.");
|
||||
if (provider.authType === PROVIDER_AUTH_TYPE.CLOUD) {
|
||||
throw new Error("Cloud providers need extended configuration — connect them from Settings after the company is created.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const api: DesktopApi = {
|
||||
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
|
||||
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
||||
launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input),
|
||||
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
||||
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
||||
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
|
||||
bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input),
|
||||
|
||||
@@ -41,13 +41,12 @@ export function Onboarding({
|
||||
onCancel: (() => void) | null;
|
||||
onCompleted: () => void;
|
||||
}) {
|
||||
const apiProviders = useMemo(
|
||||
() => snapshot.providerCatalog.filter((entry) => entry.authType === PROVIDER_AUTH_TYPE.API_KEY),
|
||||
const availableProviders = useMemo(
|
||||
() => snapshot.providerCatalog.filter((entry) => entry.authType !== PROVIDER_AUTH_TYPE.CLOUD),
|
||||
[snapshot.providerCatalog],
|
||||
);
|
||||
const firstProvider = apiProviders[0];
|
||||
if (!firstProvider) throw new Error("Provider catalog must include at least one API-key provider.");
|
||||
if (!firstProvider.defaultModel) throw new Error("Default API provider needs a default model.");
|
||||
const firstProvider = availableProviders[0];
|
||||
if (!firstProvider) throw new Error("Provider catalog must include at least one non-cloud provider.");
|
||||
|
||||
const [step, setStep] = useState<Step>("welcome");
|
||||
const [draft, setDraft] = useState<Draft>({
|
||||
@@ -58,14 +57,14 @@ export function Onboarding({
|
||||
executionMode: "ai_ceo",
|
||||
providerKey: firstProvider.key,
|
||||
providerSecret: "",
|
||||
providerModel: firstProvider.defaultModel,
|
||||
providerModel: firstProvider.defaultModel ?? firstProvider.models[0]?.id ?? "",
|
||||
goalTitle: "",
|
||||
goalDescription: "",
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const selectedProvider = requireProvider(apiProviders, draft.providerKey);
|
||||
const selectedProvider = requireProvider(availableProviders, draft.providerKey);
|
||||
const update = (patch: Partial<Draft>) => setDraft((current) => ({ ...current, ...patch }));
|
||||
|
||||
const guard = (validator: () => string | null, next: Step) => () => {
|
||||
@@ -85,7 +84,7 @@ export function Onboarding({
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const message = validateAll(draft);
|
||||
const message = validateAll(draft, availableProviders);
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
@@ -158,11 +157,11 @@ export function Onboarding({
|
||||
)}
|
||||
{step === "provider" && (
|
||||
<ProviderStep
|
||||
providers={apiProviders}
|
||||
providers={availableProviders}
|
||||
draft={draft}
|
||||
onChange={update}
|
||||
onBack={back("welcome")}
|
||||
onNext={guard(() => validateProvider(draft), "company")}
|
||||
onNext={guard(() => validateProvider(draft, availableProviders), "company")}
|
||||
/>
|
||||
)}
|
||||
{step === "company" && (
|
||||
@@ -231,6 +230,11 @@ function ProviderStep({
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const selected = providers.find((entry) => entry.key === draft.providerKey) ?? providers[0];
|
||||
if (!selected) throw new Error("No provider available for onboarding.");
|
||||
const isOAuthLike = selected.authType === "oauth_pkce" || selected.authType === "account_subscription";
|
||||
const secretLabel = isOAuthLike ? "Access token" : "API key";
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<h2 className="page-h1">Provider</h2>
|
||||
@@ -245,18 +249,33 @@ function ProviderStep({
|
||||
className={`option-card ${draft.providerKey === provider.key ? "active" : ""}`.trim()}
|
||||
onClick={() => onChange({
|
||||
providerKey: provider.key,
|
||||
providerModel: provider.defaultModel ?? draft.providerModel,
|
||||
providerModel: provider.defaultModel ?? provider.models[0]?.id ?? "",
|
||||
providerSecret: "",
|
||||
})}
|
||||
>
|
||||
<div className="row">
|
||||
<div className="opt-title" style={{ flex: 1 }}>{provider.label}</div>
|
||||
<Pill tone="violet">{provider.defaultModel ?? "model"}</Pill>
|
||||
<Pill tone="violet">{authBadge(provider.authType)}</Pill>
|
||||
</div>
|
||||
<div className="opt-sub">{provider.capabilityTags.join(" · ") || "API key"}</div>
|
||||
<div className="opt-sub">{provider.capabilityTags.join(" · ") || authBadge(provider.authType)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label="API key">
|
||||
{selected.credentialHelp && (
|
||||
<div style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5 }}>{selected.credentialHelp}</div>
|
||||
)}
|
||||
{isOAuthLike && selected.signInUrl && (
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Button
|
||||
tone="primary"
|
||||
onClick={() => void window.agentCompany.openExternalUrl(selected.signInUrl ?? "")}
|
||||
>
|
||||
Open {selected.label} sign-in
|
||||
</Button>
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{selected.signInUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
<Field label={secretLabel}>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
@@ -266,13 +285,13 @@ function ProviderStep({
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
{providers.find((entry) => entry.key === draft.providerKey)?.models.length ? (
|
||||
{selected.models.length ? (
|
||||
<select
|
||||
className="input"
|
||||
value={draft.providerModel}
|
||||
onChange={(event) => onChange({ providerModel: event.target.value })}
|
||||
>
|
||||
{providers.find((entry) => entry.key === draft.providerKey)?.models.map((model) => (
|
||||
{selected.models.map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -285,6 +304,14 @@ function ProviderStep({
|
||||
);
|
||||
}
|
||||
|
||||
function authBadge(authType: string): string {
|
||||
if (authType === "api_key") return "API key";
|
||||
if (authType === "oauth_pkce") return "OAuth";
|
||||
if (authType === "account_subscription") return "Account";
|
||||
if (authType === "cloud") return "Cloud";
|
||||
return authType;
|
||||
}
|
||||
|
||||
function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
@@ -385,9 +412,13 @@ function requireProvider(providers: ProviderCatalogEntry[], key: string): Provid
|
||||
return provider;
|
||||
}
|
||||
|
||||
function validateProvider(draft: Draft): string | null {
|
||||
function validateProvider(draft: Draft, providers: ProviderCatalogEntry[]): string | null {
|
||||
if (!draft.providerKey) return "Pick a provider.";
|
||||
if (!draft.providerSecret.trim()) return "API key is required.";
|
||||
const selected = providers.find((entry) => entry.key === draft.providerKey);
|
||||
if (!selected) return "Provider is no longer available.";
|
||||
if (!draft.providerSecret.trim()) {
|
||||
return selected.authType === "api_key" ? "API key is required." : "Access token is required.";
|
||||
}
|
||||
if (!draft.providerModel.trim()) return "Model is required.";
|
||||
return null;
|
||||
}
|
||||
@@ -399,6 +430,6 @@ function validateCompany(draft: Draft): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateAll(draft: Draft): string | null {
|
||||
return validateProvider(draft) ?? validateCompany(draft) ?? (!draft.goalTitle.trim() ? "Goal is required." : null);
|
||||
function validateAll(draft: Draft, providers: ProviderCatalogEntry[]): string | null {
|
||||
return validateProvider(draft, providers) ?? validateCompany(draft) ?? (!draft.goalTitle.trim() ? "Goal is required." : null);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export interface DesktopApi {
|
||||
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
|
||||
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
||||
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
bindSkill(input: BindSkillInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -14,6 +14,7 @@ export const IPC_CHANNELS = {
|
||||
markChatSpaceRead: "agentcompany:mark-chat-space-read",
|
||||
saveProviderCredential: "agentcompany:save-provider-credential",
|
||||
launchProviderSignIn: "agentcompany:launch-provider-sign-in",
|
||||
openExternalUrl: "agentcompany:open-external-url",
|
||||
createProviderResponse: "agentcompany:create-provider-response",
|
||||
importSkillDirectory: "agentcompany:import-skill-directory",
|
||||
bindSkill: "agentcompany:bind-skill",
|
||||
|
||||
Reference in new issue
Block a user