Files
oh-my-pi/packages/coding-agent/src/modes/components/oauth-selector.ts
T
can1357 c305872ddc feat(coding-agent): added real-time OAuth provider authentication validation with status indicators
- Added real-time OAuth provider authentication validation with visual status indicators (checking, valid, invalid).
- Added `validateAuth` and `requestRender` options to OAuthSelectorComponent for custom authentication validation and UI refresh callbacks.
- Changed OAuth provider selector to display live authentication status instead of static login state.
- Updated logout flow to refresh OAuth provider authentication state before showing selector.
2026-02-13 18:33:21 +01:00

211 lines
6.9 KiB
TypeScript

import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import type { AuthStorage } from "../../session/auth-storage";
import { DynamicBorder } from "./dynamic-border";
/**
* Component that renders an OAuth provider selector.
*/
export class OAuthSelectorComponent extends Container {
#listContainer: Container;
#allProviders: OAuthProviderInfo[] = [];
#selectedIndex: number = 0;
#mode: "login" | "logout";
#authStorage: AuthStorage;
#onSelectCallback: (providerId: string) => void;
#onCancelCallback: () => void;
#statusMessage: string | undefined;
#validateAuthCallback?: (providerId: string) => Promise<boolean>;
#requestRenderCallback?: () => void;
#authState: Map<string, "checking" | "valid" | "invalid"> = new Map();
#spinnerFrame: number = 0;
#spinnerInterval?: NodeJS.Timeout;
#validationGeneration: number = 0;
constructor(
mode: "login" | "logout",
authStorage: AuthStorage,
onSelect: (providerId: string) => void,
onCancel: () => void,
options?: {
validateAuth?: (providerId: string) => Promise<boolean>;
requestRender?: () => void;
},
) {
super();
this.#mode = mode;
this.#authStorage = authStorage;
this.#onSelectCallback = onSelect;
this.#onCancelCallback = onCancel;
this.#validateAuthCallback = options?.validateAuth;
this.#requestRenderCallback = options?.requestRender;
// Load all OAuth providers
this.#loadProviders();
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
this.addChild(new TruncatedText(theme.bold(title)));
this.addChild(new Spacer(1));
// Create list container
this.#listContainer = new Container();
this.addChild(this.#listContainer);
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
// Initial render
this.#updateList();
this.#startValidation();
}
stopValidation(): void {
this.#validationGeneration += 1;
this.#stopSpinner();
}
#loadProviders(): void {
this.#allProviders = getOAuthProviders();
}
#startValidation(): void {
if (!this.#validateAuthCallback) return;
const generation = this.#validationGeneration + 1;
this.#validationGeneration = generation;
let pending = 0;
for (const provider of this.#allProviders) {
if (!this.#authStorage.hasAuth(provider.id)) {
this.#authState.delete(provider.id);
continue;
}
this.#authState.set(provider.id, "checking");
pending += 1;
void this.#validateProvider(provider.id, generation);
}
if (pending > 0) {
this.#startSpinner();
this.#updateList();
this.#requestRenderCallback?.();
}
}
async #validateProvider(providerId: string, generation: number): Promise<void> {
if (!this.#validateAuthCallback) return;
let isValid = false;
try {
isValid = await this.#validateAuthCallback(providerId);
} catch {
isValid = false;
}
if (generation !== this.#validationGeneration) return;
this.#authState.set(providerId, isValid ? "valid" : "invalid");
if (![...this.#authState.values()].includes("checking")) {
this.#stopSpinner();
}
this.#updateList();
this.#requestRenderCallback?.();
}
#startSpinner(): void {
if (this.#spinnerInterval) return;
this.#spinnerInterval = setInterval(() => {
const frameCount = theme.spinnerFrames.length;
if (frameCount > 0) {
this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
}
this.#updateList();
this.#requestRenderCallback?.();
}, 80);
}
#stopSpinner(): void {
if (this.#spinnerInterval) {
clearInterval(this.#spinnerInterval);
this.#spinnerInterval = undefined;
}
}
#getStatusIndicator(providerId: string): string {
const state = this.#authState.get(providerId);
if (state === "checking") {
const frameCount = theme.spinnerFrames.length;
const spinner = frameCount > 0 ? theme.spinnerFrames[this.#spinnerFrame % frameCount] : theme.status.pending;
return theme.fg("warning", ` ${spinner} checking`);
}
if (state === "invalid") {
return theme.fg("error", ` ${theme.status.error} invalid`);
}
if (state === "valid") {
return theme.fg("success", ` ${theme.status.success} logged in`);
}
return this.#authStorage.hasAuth(providerId) ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
}
#updateList(): void {
this.#listContainer.clear();
for (let i = 0; i < this.#allProviders.length; i++) {
const provider = this.#allProviders[i];
if (!provider) continue;
const isSelected = i === this.#selectedIndex;
const isAvailable = provider.available;
const statusIndicator = this.#getStatusIndicator(provider.id);
let line = "";
if (isSelected) {
const prefix = theme.fg("accent", `${theme.nav.cursor} `);
const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name);
line = prefix + text + statusIndicator;
} else {
const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
line = text + statusIndicator;
}
this.#listContainer.addChild(new TruncatedText(line, 0, 0));
}
// Show "no providers" if empty
if (this.#allProviders.length === 0) {
const message =
this.#mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
}
if (this.#statusMessage) {
this.#listContainer.addChild(new Spacer(1));
this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0));
}
}
handleInput(keyData: string): void {
// Up arrow
if (matchesKey(keyData, "up")) {
if (this.#allProviders.length > 0) {
this.#selectedIndex = this.#selectedIndex === 0 ? this.#allProviders.length - 1 : this.#selectedIndex - 1;
}
this.#statusMessage = undefined;
this.#updateList();
}
// Down arrow
else if (matchesKey(keyData, "down")) {
if (this.#allProviders.length > 0) {
this.#selectedIndex = this.#selectedIndex === this.#allProviders.length - 1 ? 0 : this.#selectedIndex + 1;
}
this.#statusMessage = undefined;
this.#updateList();
}
// Enter
else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
const selectedProvider = this.#allProviders[this.#selectedIndex];
if (selectedProvider?.available) {
this.#statusMessage = undefined;
this.stopValidation();
this.#onSelectCallback(selectedProvider.id);
} else if (selectedProvider) {
this.#statusMessage = "Provider unavailable in this environment.";
this.#updateList();
}
}
// Escape or Ctrl+C
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
this.stopValidation();
this.#onCancelCallback();
}
}
}