Replace token-paste with native device-code/PKCE provider sign-in
Implement the official OAuth login flows natively (ported from pi-ai's reference): GitHub Copilot device-code, GPT account (ChatGPT Codex) and Google Antigravity authorization-code + PKCE via a loopback callback server. Obtained credentials are stored as JSON through the existing secret vault. Onboarding and Settings now run the real sign-in instead of pasting an access token.
This commit is contained in:
1 parent
d5bf6c6b5f
commit
49a9f16221
24 files changed
+1057
-64
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
type ProviderCatalogEntry,
|
||||
} from "../../../shared/domain";
|
||||
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
|
||||
import { ProviderOAuthConnect } from "../shared/ProviderOAuthConnect";
|
||||
import type { SnapshotActions } from "./useSnapshot";
|
||||
|
||||
type Step = "welcome" | "provider" | "company" | "mode" | "mission";
|
||||
@@ -244,7 +245,6 @@ function ProviderStep({
|
||||
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">
|
||||
@@ -275,26 +275,25 @@ function ProviderStep({
|
||||
{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"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value })}
|
||||
{isOAuthLike ? (
|
||||
<ProviderOAuthConnect
|
||||
key={selected.key}
|
||||
providerKey={selected.key}
|
||||
label={selected.label}
|
||||
buildInput={() => ({ providerKey: selected.key })}
|
||||
onComplete={(secret) => onChange({ providerSecret: secret ?? "" })}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="API key">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={draft.providerSecret}
|
||||
onChange={(event) => onChange({ providerSecret: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Model">
|
||||
{selected.models.length ? (
|
||||
<select
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button, Field, Pill } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ProviderCatalogEntry,
|
||||
@@ -41,19 +42,19 @@ type WorkspaceScope = "organization" | "workspace";
|
||||
|
||||
export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, catalog, agents, workspaces, providerRateLimits, providerRequests, actions }: ProvidersPanelProps) {
|
||||
const [active, setActive] = useState<ProviderRecord | null>(null);
|
||||
const [signInLaunched, setSignInLaunched] = useState(false);
|
||||
const connected = providers.filter((provider) => provider.enabled);
|
||||
const available = providers.filter((provider) => !provider.enabled);
|
||||
const activeCatalogEntry = active ? catalog.find((entry) => entry.key === active.providerKey) ?? null : null;
|
||||
const activeIsOAuth =
|
||||
activeCatalogEntry?.authType === "oauth_pkce" || activeCatalogEntry?.authType === "account_subscription";
|
||||
|
||||
const close = () => {
|
||||
setActive(null);
|
||||
setSignInLaunched(false);
|
||||
};
|
||||
|
||||
const save = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!active || !activeCatalogEntry) return;
|
||||
if (!active || !activeCatalogEntry || activeIsOAuth) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
const secret = composeSecret(activeCatalogEntry, form);
|
||||
void actions.run(() => window.agentCompany.saveProviderCredential({
|
||||
@@ -65,12 +66,6 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
})).then((result) => { if (result) close(); });
|
||||
};
|
||||
|
||||
const launchSignIn = () => {
|
||||
if (!active) return;
|
||||
setSignInLaunched(true);
|
||||
void actions.run(() => window.agentCompany.launchProviderSignIn({ orgId, providerId: active.id, employeeId }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Providers</h1>
|
||||
@@ -124,16 +119,22 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
title={active ? `Connect ${active.label}` : "Connect provider"}
|
||||
open={Boolean(active)}
|
||||
submitLabel="Save"
|
||||
hideSubmit={activeIsOAuth}
|
||||
onClose={close}
|
||||
onSubmit={save}
|
||||
>
|
||||
{active && activeCatalogEntry && (
|
||||
<ProviderConnectFields
|
||||
provider={active}
|
||||
entry={activeCatalogEntry}
|
||||
signInLaunched={signInLaunched}
|
||||
onLaunchSignIn={launchSignIn}
|
||||
/>
|
||||
activeIsOAuth ? (
|
||||
<ProviderOAuthFields
|
||||
orgId={orgId}
|
||||
employeeId={employeeId}
|
||||
provider={active}
|
||||
entry={activeCatalogEntry}
|
||||
onConnected={() => { void actions.reload(); close(); }}
|
||||
/>
|
||||
) : (
|
||||
<ProviderConnectFields provider={active} entry={activeCatalogEntry} />
|
||||
)
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
@@ -274,13 +275,9 @@ function ProviderPolicyRow({
|
||||
function ProviderConnectFields({
|
||||
provider,
|
||||
entry,
|
||||
signInLaunched,
|
||||
onLaunchSignIn,
|
||||
}: {
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
signInLaunched: boolean;
|
||||
onLaunchSignIn: () => void;
|
||||
}) {
|
||||
if (entry.authType === "api_key") {
|
||||
return (
|
||||
@@ -291,24 +288,48 @@ function ProviderConnectFields({
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (entry.authType === "oauth_pkce" || entry.authType === "account_subscription") {
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<Button tone={signInLaunched ? "ghost" : "primary"} onClick={onLaunchSignIn}>
|
||||
{signInLaunched ? "Re-open sign-in window" : `Open ${entry.label} sign-in`}
|
||||
</Button>
|
||||
{entry.signInUrl && <span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{entry.signInUrl}</span>}
|
||||
</div>
|
||||
<Field label="Access token"><input className="input" name="secret" type="password" required /></Field>
|
||||
<ModelPicker entry={entry} provider={provider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <CloudFieldsForm entry={entry} provider={provider} />;
|
||||
}
|
||||
|
||||
function ProviderOAuthFields({
|
||||
orgId,
|
||||
employeeId,
|
||||
provider,
|
||||
entry,
|
||||
onConnected,
|
||||
}: {
|
||||
orgId: string;
|
||||
employeeId: string;
|
||||
provider: ProviderRecord;
|
||||
entry: ProviderCatalogEntry;
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
const [model, setModel] = useState(provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "");
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontSize: 12.5, color: "var(--text-3)", lineHeight: 1.5, margin: 0 }}>{entry.credentialHelp}</p>
|
||||
<Field label="Default model">
|
||||
{entry.models.length ? (
|
||||
<select className="input" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||
{entry.models.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input" value={model} onChange={(event) => setModel(event.target.value)} />
|
||||
)}
|
||||
</Field>
|
||||
<ProviderOAuthConnect
|
||||
key={entry.key}
|
||||
providerKey={entry.key}
|
||||
label={entry.label}
|
||||
buildInput={() => ({ providerKey: entry.key, orgId, providerId: provider.id, employeeId, model })}
|
||||
onComplete={() => onConnected()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) {
|
||||
const defaultValue = provider.model ?? entry.defaultModel ?? entry.models[0]?.id ?? "";
|
||||
if (entry.models.length === 0) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export function Dialog({
|
||||
submitLabel,
|
||||
onClose,
|
||||
onSubmit,
|
||||
hideSubmit = false,
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
@@ -15,6 +16,7 @@ export function Dialog({
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
hideSubmit?: boolean;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -23,8 +25,8 @@ export function Dialog({
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<div style={{ display: "grid", gap: 12 }}>{children}</div>
|
||||
<div className="modal-actions">
|
||||
<Button tone="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button tone="primary" type="submit">{submitLabel}</Button>
|
||||
<Button tone="ghost" onClick={onClose}>{hideSubmit ? "Close" : "Cancel"}</Button>
|
||||
{!hideSubmit && <Button tone="primary" type="submit">{submitLabel}</Button>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { StartProviderOAuthInput } from "../../../shared/commands";
|
||||
import { Button, Icon } from "./ui";
|
||||
|
||||
type Phase = "idle" | "running" | "done" | "error";
|
||||
|
||||
/**
|
||||
* Runs a provider's native OAuth login (device-code / PKCE) and reports the
|
||||
* outcome. The browser opens automatically; for the device flow the user code
|
||||
* is shown here, which is the only interactive instruction required.
|
||||
*/
|
||||
export function ProviderOAuthConnect({
|
||||
providerKey,
|
||||
label,
|
||||
buildInput,
|
||||
onComplete,
|
||||
}: {
|
||||
providerKey: string;
|
||||
label: string;
|
||||
buildInput: () => StartProviderOAuthInput;
|
||||
onComplete: (secret: string | null) => void;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [instruction, setInstruction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
window.agentCompany.subscribe((event) => {
|
||||
if (event.type !== "provider-oauth" || event.providerKey !== providerKey) return;
|
||||
if (event.phase === "authorize" && event.instructions) setInstruction(event.instructions);
|
||||
}),
|
||||
[providerKey],
|
||||
);
|
||||
|
||||
const run = () => {
|
||||
setPhase("running");
|
||||
setInstruction(null);
|
||||
setError(null);
|
||||
void window.agentCompany.startProviderOAuth(buildInput()).then((result) => {
|
||||
if (result.ok) {
|
||||
setPhase("done");
|
||||
onComplete(result.data.secret);
|
||||
} else {
|
||||
setPhase("error");
|
||||
setError(result.error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cancel = () => void window.agentCompany.cancelProviderOAuth({ providerKey });
|
||||
|
||||
if (phase === "running") {
|
||||
return (
|
||||
<div className="oauth-connect">
|
||||
<div className="oauth-connect-row">
|
||||
<span className="spinner" aria-hidden />
|
||||
<span>Waiting for {label} sign-in</span>
|
||||
<button type="button" className="link-button" onClick={cancel}>Cancel</button>
|
||||
</div>
|
||||
{instruction && <div className="oauth-connect-code">{instruction}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "done") {
|
||||
return (
|
||||
<div className="oauth-connect-row done">
|
||||
<Icon name="check" size={14} color="var(--green)" />
|
||||
<span>Signed in to {label}</span>
|
||||
<button type="button" className="link-button" onClick={run}>Redo</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oauth-connect">
|
||||
<Button tone="primary" onClick={run}>Sign in with {label}</Button>
|
||||
{error && <div className="oauth-connect-error">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,3 +180,28 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
|
||||
color: var(--text-3);
|
||||
}
|
||||
.link-button:hover { color: var(--text-1); }
|
||||
|
||||
.oauth-connect { display: flex; flex-direction: column; gap: 8px; }
|
||||
.oauth-connect-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); }
|
||||
.oauth-connect-row.done { color: var(--text-1); }
|
||||
.oauth-connect-row .link-button { margin-left: auto; }
|
||||
.oauth-connect-code {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-1);
|
||||
background: var(--panel-3);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.oauth-connect-error { font-size: 12px; color: var(--red); }
|
||||
.spinner {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--text-2);
|
||||
animation: spinner-rotate 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spinner-rotate { to { transform: rotate(360deg); } }
|
||||
Reference in new issue
Block a user