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:
ZacharyZhang-NY committed 2026-05-21 22:15:15 -04:00
1 parent 2c3760ca11
commit e7eb829b48
6 files changed
+69 -23

No files matched your search

+13 -1
View File
@@ -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;
}
+1
View File
@@ -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>>;
+1
View File
@@ -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",