- 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.
211 lines
6.9 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|