feat: added providers management settings with ability to add or remove providers (#76)
* feat: implement adding opencode providers in openchamber settings * feat: implement provider authentication management with removal functionality
This commit is contained in:
committed by
GitHub
parent
ff874cc33d
commit
8f9facb561
@@ -4,6 +4,7 @@ mod commands;
|
||||
mod logging;
|
||||
mod assistant_notifications;
|
||||
mod session_activity;
|
||||
mod opencode_auth;
|
||||
mod opencode_config;
|
||||
mod opencode_manager;
|
||||
mod window_state;
|
||||
@@ -1493,6 +1494,58 @@ async fn handle_config_routes(
|
||||
));
|
||||
}
|
||||
|
||||
// Handle provider auth removal: DELETE /api/provider/:providerId/auth
|
||||
if let Some(rest) = path.strip_prefix("/api/provider/") {
|
||||
if let Some(provider_id) = rest.strip_suffix("/auth") {
|
||||
if method == Method::DELETE {
|
||||
let trimmed = provider_id.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(config_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Provider ID is required",
|
||||
));
|
||||
}
|
||||
|
||||
match opencode_auth::remove_provider_auth(trimmed).await {
|
||||
Ok(removed) => {
|
||||
if let Err(resp) = refresh_opencode_after_config_change(
|
||||
&state,
|
||||
&format!("provider {} disconnected", trimmed),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
return Ok(json_response(
|
||||
StatusCode::OK,
|
||||
ConfigActionResponse {
|
||||
success: true,
|
||||
requires_reload: true,
|
||||
message: if removed {
|
||||
"Provider disconnected successfully".to_string()
|
||||
} else {
|
||||
"Provider was not connected".to_string()
|
||||
},
|
||||
reload_delay_ms: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"[desktop:config] Failed to disconnect provider {}: {}",
|
||||
trimmed, err
|
||||
);
|
||||
return Ok(config_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
|
||||
@@ -1585,9 +1638,16 @@ async fn proxy_to_opencode(
|
||||
let origin_path = original.0.path().to_string();
|
||||
let method = req.method().clone();
|
||||
|
||||
// Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth)
|
||||
let is_provider_auth_delete = method == Method::DELETE
|
||||
&& origin_path.starts_with("/api/provider/")
|
||||
&& origin_path.ends_with("/auth")
|
||||
&& origin_path != "/api/provider/auth"; // Exclude GET /api/provider/auth
|
||||
|
||||
let is_desktop_config_route = origin_path.starts_with("/api/config/agents/")
|
||||
|| origin_path.starts_with("/api/config/commands/")
|
||||
|| origin_path == "/api/config/reload";
|
||||
|| origin_path == "/api/config/reload"
|
||||
|| is_provider_auth_delete;
|
||||
|
||||
if is_desktop_config_route {
|
||||
return handle_config_routes(state, &origin_path, method, req).await;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
/// Get OpenCode data directory path (~/.local/share/opencode)
|
||||
fn get_data_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("Cannot determine home directory")
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
/// Get auth file path
|
||||
fn get_auth_file() -> PathBuf {
|
||||
get_data_dir().join("auth.json")
|
||||
}
|
||||
|
||||
/// Ensure data directory exists
|
||||
async fn ensure_data_dir() -> Result<()> {
|
||||
let data_dir = get_data_dir();
|
||||
fs::create_dir_all(&data_dir).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read auth.json file
|
||||
pub async fn read_auth() -> Result<Value> {
|
||||
let auth_file = get_auth_file();
|
||||
|
||||
if !auth_file.exists() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&auth_file).await?;
|
||||
let trimmed = content.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
serde_json::from_str(trimmed).map_err(|e| anyhow!("Failed to parse auth file: {}", e))
|
||||
}
|
||||
|
||||
/// Write auth.json file with backup
|
||||
pub async fn write_auth(auth: &Value) -> Result<()> {
|
||||
ensure_data_dir().await?;
|
||||
|
||||
let auth_file = get_auth_file();
|
||||
|
||||
// Create backup before writing
|
||||
if auth_file.exists() {
|
||||
let file_name = auth_file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("Invalid auth file name"))?;
|
||||
|
||||
let backup_path = auth_file.with_file_name(format!("{file_name}.openchamber.backup"));
|
||||
fs::copy(&auth_file, &backup_path).await?;
|
||||
info!("Created auth backup: {}", backup_path.display());
|
||||
}
|
||||
|
||||
let json_string = serde_json::to_string_pretty(auth)?;
|
||||
fs::write(&auth_file, json_string).await?;
|
||||
info!("Successfully wrote auth file");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove provider auth entry from auth.json
|
||||
pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
|
||||
if provider_id.is_empty() {
|
||||
return Err(anyhow!("Provider ID is required"));
|
||||
}
|
||||
|
||||
let mut auth = read_auth().await?;
|
||||
|
||||
let auth_obj = auth
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
|
||||
|
||||
if !auth_obj.contains_key(provider_id) {
|
||||
info!(
|
||||
"Provider {} not found in auth file, nothing to remove",
|
||||
provider_id
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
auth_obj.remove(provider_id);
|
||||
write_auth(&auth).await?;
|
||||
info!("Removed provider auth: {}", provider_id);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get provider auth entry
|
||||
pub async fn get_provider_auth(provider_id: &str) -> Result<Option<Value>> {
|
||||
let auth = read_auth().await?;
|
||||
|
||||
Ok(auth
|
||||
.as_object()
|
||||
.and_then(|obj| obj.get(provider_id))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
/// List all provider IDs with auth
|
||||
pub async fn list_provider_auths() -> Result<Vec<String>> {
|
||||
let auth = read_auth().await?;
|
||||
|
||||
Ok(auth
|
||||
.as_object()
|
||||
.map(|obj| obj.keys().cloned().collect())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
@@ -176,6 +176,15 @@ window.opencodeDesktop = {
|
||||
async getHomeDirectory() {
|
||||
return { success: true, path: homeDirectory || null };
|
||||
},
|
||||
async openExternal(url: string) {
|
||||
try {
|
||||
await open(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error opening external link:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
markRendererReady() {
|
||||
|
||||
},
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,11 +1,973 @@
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '../SectionPlaceholder';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine } from '@remixicon/react';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ModelMetadata } from '@/types';
|
||||
|
||||
const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const formatTokens = (value?: number | null) => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
const formatted = COMPACT_NUMBER_FORMATTER.format(value);
|
||||
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
|
||||
};
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
interface AuthMethod {
|
||||
type?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
help?: string;
|
||||
method?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const normalizeAuthType = (method: AuthMethod) => {
|
||||
const raw = typeof method.type === 'string' ? method.type : '';
|
||||
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
|
||||
const merged = `${raw} ${label}`.toLowerCase();
|
||||
if (merged.includes('oauth')) return 'oauth';
|
||||
if (merged.includes('api')) return 'api';
|
||||
return raw.toLowerCase();
|
||||
};
|
||||
|
||||
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
|
||||
if (!isRecord(payload)) {
|
||||
return {};
|
||||
}
|
||||
const result: Record<string, AuthMethod[]> = {};
|
||||
for (const [providerId, value] of Object.entries(payload)) {
|
||||
if (Array.isArray(value)) {
|
||||
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
|
||||
if (typeof entry === 'string') {
|
||||
return { id: entry };
|
||||
}
|
||||
if (!isRecord(entry)) {
|
||||
return null;
|
||||
}
|
||||
const idCandidate =
|
||||
(typeof entry.id === 'string' && entry.id) ||
|
||||
(typeof entry.providerID === 'string' && entry.providerID) ||
|
||||
(typeof entry.slug === 'string' && entry.slug) ||
|
||||
(typeof entry.name === 'string' && entry.name);
|
||||
if (!idCandidate) {
|
||||
return null;
|
||||
}
|
||||
const nameCandidate = typeof entry.name === 'string' ? entry.name : undefined;
|
||||
return { id: idCandidate, name: nameCandidate };
|
||||
};
|
||||
|
||||
const parseProvidersPayload = (payload: unknown): ProviderOption[] => {
|
||||
let entries: unknown[] = [];
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
entries = payload;
|
||||
} else if (isRecord(payload)) {
|
||||
if (Array.isArray(payload.all)) {
|
||||
entries = payload.all;
|
||||
} else if (Array.isArray(payload.providers)) {
|
||||
entries = payload.providers;
|
||||
}
|
||||
}
|
||||
|
||||
const mapped = entries
|
||||
.map((entry) => normalizeProviderEntry(entry))
|
||||
.filter((entry): entry is ProviderOption => Boolean(entry));
|
||||
|
||||
const seen = new Set<string>();
|
||||
return mapped.filter((entry) => {
|
||||
if (seen.has(entry.id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(entry.id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const ProvidersPage: React.FC = () => {
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||
|
||||
const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState<Record<string, AuthMethod[]>>({});
|
||||
const [authLoading, setAuthLoading] = React.useState(false);
|
||||
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
|
||||
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
|
||||
const [modelQuery, setModelQuery] = React.useState('');
|
||||
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
|
||||
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
|
||||
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
|
||||
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
|
||||
const [availableLoading, setAvailableLoading] = React.useState(false);
|
||||
const [availableError, setAvailableError] = React.useState<string | null>(null);
|
||||
const [candidateProviderId, setCandidateProviderId] = React.useState('');
|
||||
const [providerSearchQuery, setProviderSearchQuery] = React.useState('');
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProviderId && providers.length > 0) {
|
||||
setSelectedProvider(providers[0].id);
|
||||
}
|
||||
}, [providers, selectedProviderId, setSelectedProvider]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadAuthMethods = async () => {
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/provider/auth', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Auth methods request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!isMounted) return;
|
||||
setAuthMethodsByProvider(parseAuthPayload(payload));
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load provider auth methods:', error);
|
||||
toast.error('Failed to load provider authentication methods');
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAuthMethods();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadAvailableProviders = async () => {
|
||||
setAvailableLoading(true);
|
||||
setAvailableError(null);
|
||||
try {
|
||||
const response = await fetch('/api/provider', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provider list request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!isMounted) return;
|
||||
setAvailableProviders(parseProvidersPayload(payload));
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to load available providers:', error);
|
||||
setAvailableError('Unable to load provider list');
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setAvailableLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAvailableProviders();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connectedProviderIds = React.useMemo(
|
||||
() => new Set(providers.map((provider) => provider.id)),
|
||||
[providers]
|
||||
);
|
||||
|
||||
const unconnectedProviders = React.useMemo(
|
||||
() => availableProviders.filter((provider) => !connectedProviderIds.has(provider.id)),
|
||||
[availableProviders, connectedProviderIds]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedProviderId !== ADD_PROVIDER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!candidateProviderId && unconnectedProviders.length > 0) {
|
||||
setCandidateProviderId(unconnectedProviders[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) {
|
||||
setCandidateProviderId(unconnectedProviders[0]?.id ?? '');
|
||||
}
|
||||
}, [selectedProviderId, candidateProviderId, unconnectedProviders]);
|
||||
|
||||
const selectedProvider = providers.find((provider) => provider.id === selectedProviderId);
|
||||
|
||||
const handleSaveApiKey = async (providerId: string) => {
|
||||
const apiKey = apiKeyInputs[providerId]?.trim() ?? '';
|
||||
if (!apiKey) {
|
||||
toast.error('API key is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const busyKey = `api:${providerId}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/auth/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'api', key: apiKey }),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to save API key';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('API key saved');
|
||||
setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' }));
|
||||
await reloadOpenCodeConfiguration();
|
||||
await loadProviders();
|
||||
setSelectedProvider(providerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
toast.error('Failed to save API key');
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
|
||||
const busyKey = `oauth:${providerId}:${methodIndex}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/authorize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: methodIndex }),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to start OAuth flow';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const payloadRecord = isRecord(payload) ? payload : {};
|
||||
const dataRecord = isRecord(payloadRecord.data) ? payloadRecord.data : payloadRecord;
|
||||
const urlCandidate =
|
||||
(typeof dataRecord.url === 'string' && dataRecord.url) ||
|
||||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
|
||||
(typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) ||
|
||||
undefined;
|
||||
const instructions =
|
||||
(typeof dataRecord.instructions === 'string' && dataRecord.instructions) ||
|
||||
(typeof dataRecord.message === 'string' && dataRecord.message) ||
|
||||
undefined;
|
||||
const userCode =
|
||||
(typeof dataRecord.user_code === 'string' && dataRecord.user_code) ||
|
||||
(typeof dataRecord.code === 'string' && dataRecord.code) ||
|
||||
(typeof dataRecord.userCode === 'string' && dataRecord.userCode) ||
|
||||
undefined;
|
||||
|
||||
if (!urlCandidate && !instructions && !userCode) {
|
||||
throw new Error('No OAuth details returned');
|
||||
}
|
||||
|
||||
const detailsKey = `${providerId}:${methodIndex}`;
|
||||
setOauthDetails((prev) => ({
|
||||
...prev,
|
||||
[detailsKey]: {
|
||||
url: urlCandidate,
|
||||
instructions,
|
||||
userCode,
|
||||
},
|
||||
}));
|
||||
|
||||
if (urlCandidate) {
|
||||
window.open(urlCandidate, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
setPendingOAuth({ providerId, methodIndex });
|
||||
toast.message('Complete the OAuth flow in your browser');
|
||||
} catch (error) {
|
||||
console.error('Failed to start OAuth flow:', error);
|
||||
toast.error('Failed to start OAuth flow');
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthComplete = async (providerId: string, methodIndex: number) => {
|
||||
const codeKey = `${providerId}:${methodIndex}`;
|
||||
const code = oauthCodes[codeKey]?.trim();
|
||||
|
||||
const busyKey = `oauth-complete:${providerId}:${methodIndex}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const requestBody: { method: number; code?: string } = { method: methodIndex };
|
||||
if (code) {
|
||||
requestBody.code = code;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const responsePayload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = responsePayload?.error || 'Failed to complete OAuth flow';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('OAuth connection completed');
|
||||
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
|
||||
setPendingOAuth(null);
|
||||
await reloadOpenCodeConfiguration();
|
||||
await loadProviders();
|
||||
setSelectedProvider(providerId);
|
||||
} catch (error) {
|
||||
console.error('Failed to complete OAuth flow:', error);
|
||||
toast.error('Failed to complete OAuth flow');
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyOAuthLink = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success('OAuth link copied');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy OAuth link:', error);
|
||||
toast.error('Failed to copy OAuth link');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyOAuthCode = async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
toast.success('Device code copied');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy device code:', error);
|
||||
toast.error('Failed to copy device code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectProvider = async (providerId: string) => {
|
||||
const busyKey = `disconnect:${providerId}`;
|
||||
setAuthBusyKey(busyKey);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || 'Failed to disconnect provider';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
toast.success('Provider disconnected');
|
||||
await reloadOpenCodeConfiguration();
|
||||
await loadProviders();
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect provider:', error);
|
||||
toast.error('Failed to disconnect provider');
|
||||
} finally {
|
||||
setAuthBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isAddMode = selectedProviderId === ADD_PROVIDER_ID;
|
||||
|
||||
if (!isAddMode && providers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">No providers detected</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAddMode) {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">Connect provider</h1>
|
||||
<p className="typography-body text-muted-foreground">
|
||||
Choose a provider to connect and set up its authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Provider</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Select a provider that is not connected yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{availableLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading providers…</p>
|
||||
) : availableError ? (
|
||||
<p className="typography-meta text-muted-foreground">{availableError}</p>
|
||||
) : unconnectedProviders.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground">All available providers are already connected.</p>
|
||||
) : (
|
||||
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
|
||||
setProviderDropdownOpen(open);
|
||||
if (!open) setProviderSearchQuery('');
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-3 py-2 typography-ui-label",
|
||||
"hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
<span className={candidateProviderId ? "text-foreground" : "text-muted-foreground"}>
|
||||
{candidateProviderId
|
||||
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
|
||||
: "Select provider"}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[200px] p-0"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 border-b px-3 py-2"
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiSearchLine className="h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={providerSearchQuery}
|
||||
onChange={(e) => setProviderSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="Search providers..."
|
||||
className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
|
||||
{(() => {
|
||||
const filtered = unconnectedProviders.filter(p => {
|
||||
const query = providerSearchQuery.toLowerCase();
|
||||
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
return <p className="py-4 text-center typography-meta text-muted-foreground">No providers found</p>;
|
||||
}
|
||||
return filtered.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
onSelect={() => {
|
||||
setCandidateProviderId(provider.id);
|
||||
setProviderDropdownOpen(false);
|
||||
setProviderSearchQuery('');
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{provider.name || provider.id}</span>
|
||||
{candidateProviderId === provider.id && (
|
||||
<RiCheckLine className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
));
|
||||
})()}
|
||||
</ScrollableOverlay>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{candidateProviderId && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2>
|
||||
|
||||
{authLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading authentication methods…</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">API key</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[candidateProviderId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[candidateProviderId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
className="h-8"
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? 'Saving…' : 'Save key'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Keys are sent directly to OpenCode and never stored by OpenChamber.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
|
||||
const candidateOAuthMethods = candidateAuthMethods.filter(
|
||||
(method) => normalizeAuthType(method) === 'oauth'
|
||||
);
|
||||
|
||||
if (candidateOAuthMethods.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{candidateOAuthMethods.map((method, index) => {
|
||||
const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
|
||||
const codeKey = `${candidateProviderId}:${index}`;
|
||||
const isPending =
|
||||
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
|
||||
|
||||
return (
|
||||
<div key={`${candidateProviderId}-${methodLabel}`} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label font-medium text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOAuthStart(candidateProviderId, index)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
|
||||
className="h-8"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
|
||||
className="h-8"
|
||||
>
|
||||
Copy code
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
asChild
|
||||
>
|
||||
<a href={oauthDetails[codeKey]?.url} target="_blank" rel="noopener noreferrer">
|
||||
Open link
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
|
||||
className="h-8"
|
||||
>
|
||||
Copy link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Authorization code (if required)"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
|
||||
className="h-8"
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}`
|
||||
? 'Saving…'
|
||||
: 'Complete'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedProvider) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a provider from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Review details and configure auth</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||
|
||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
|
||||
|
||||
const filteredModels = providerModels.filter((model) => {
|
||||
const name = typeof model?.name === 'string' ? model.name : '';
|
||||
const id = typeof model?.id === 'string' ? model.id : '';
|
||||
const query = modelQuery.trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<SectionPlaceholder sectionId="providers" variant="page" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5" />
|
||||
<h1 className="typography-ui-header font-semibold text-lg">
|
||||
{selectedProvider.name || selectedProvider.id}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="typography-body text-muted-foreground">
|
||||
Provider ID: {selectedProvider.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2>
|
||||
|
||||
{authLoading ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading authentication methods…</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">API key</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
||||
onChange={(event) =>
|
||||
setApiKeyInputs((prev) => ({
|
||||
...prev,
|
||||
[selectedProvider.id]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
className="h-8"
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving…' : 'Save key'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Keys are sent directly to OpenCode and never stored by OpenChamber.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border/40">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDisconnectProvider(selectedProvider.id)}
|
||||
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting…' : 'Disconnect provider'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthAuthMethods.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{oauthAuthMethods.map((method, index) => {
|
||||
const methodLabel = method.label || method.name || `OAuth method ${index + 1}`;
|
||||
const codeKey = `${selectedProvider.id}:${index}`;
|
||||
const isPending =
|
||||
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
|
||||
|
||||
return (
|
||||
<div key={`${selectedProvider.id}-${methodLabel}`} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label font-medium text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOAuthStart(selectedProvider.id, index)}
|
||||
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
|
||||
className="h-8"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
|
||||
className="h-8"
|
||||
>
|
||||
Copy code
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
asChild
|
||||
>
|
||||
<a href={oauthDetails[codeKey]?.url} target="_blank" rel="noopener noreferrer">
|
||||
Open link
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
|
||||
className="h-8"
|
||||
>
|
||||
Copy link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isPending && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Paste authorization code"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
|
||||
className="h-8"
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`
|
||||
? 'Saving…'
|
||||
: 'Complete'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Browse and filter models exposed by this provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={modelQuery}
|
||||
onChange={(event) => setModelQuery(event.target.value)}
|
||||
placeholder="Filter models..."
|
||||
/>
|
||||
|
||||
<div className="border-t border-border/40">
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground py-3 px-2">No models match this filter.</p>
|
||||
) : (
|
||||
filteredModels.map((model) => {
|
||||
const modelId = typeof model?.id === 'string' ? model.id : '';
|
||||
const modelName = typeof model?.name === 'string' ? model.name : modelId;
|
||||
const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined;
|
||||
|
||||
const contextTokens = formatTokens(metadata?.limit?.context);
|
||||
const outputTokens = formatTokens(metadata?.limit?.output);
|
||||
|
||||
const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = [];
|
||||
if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: 'Tool calling' });
|
||||
if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: 'Reasoning' });
|
||||
if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/40"
|
||||
>
|
||||
<span className="typography-meta font-medium text-foreground truncate flex-1 min-w-0">
|
||||
{modelName}
|
||||
</span>
|
||||
{(contextTokens || outputTokens) && (
|
||||
<span className="typography-micro text-muted-foreground flex-shrink-0">
|
||||
{contextTokens ? `${contextTokens} ctx` : ''}
|
||||
{contextTokens && outputTokens ? ' · ' : ''}
|
||||
{outputTokens ? `${outputTokens} out` : ''}
|
||||
</span>
|
||||
)}
|
||||
{capabilityIcons.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{capabilityIcons.map(({ key, icon: Icon, label }) => (
|
||||
<span
|
||||
key={key}
|
||||
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,86 @@
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '../SectionPlaceholder';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { RiAddLine, RiStackLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ADD_PROVIDER_ID = '__add_provider__';
|
||||
|
||||
export const ProvidersSidebar: React.FC = () => {
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="px-3 py-2">
|
||||
<SectionPlaceholder sectionId="providers" variant="sidebar" />
|
||||
</ScrollableOverlay>
|
||||
<div className="flex h-full flex-col bg-sidebar">
|
||||
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Providers</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{providers.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => setSelectedProvider(ADD_PROVIDER_ID)}
|
||||
aria-label="Connect provider"
|
||||
title="Connect provider"
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
||||
{providers.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiStackLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No providers found</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
|
||||
</div>
|
||||
) : (
|
||||
providers.map((provider) => {
|
||||
const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
|
||||
const isSelected = provider.id === selectedProviderId;
|
||||
|
||||
return (
|
||||
<div key={provider.id} className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedProvider(provider.id)}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className={cn(
|
||||
"typography-ui-label font-medium truncate flex-1 min-w-0",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
{provider.name || provider.id}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground flex-shrink-0">
|
||||
{modelCount}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -70,7 +70,6 @@ function SelectContent({
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<ScrollableOverlay
|
||||
as={SelectPrimitive.Viewport}
|
||||
outerClassName="max-h-[var(--radix-select-content-available-height)] w-full"
|
||||
@@ -82,7 +81,6 @@ function SelectContent({
|
||||
>
|
||||
{children}
|
||||
</ScrollableOverlay>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ export type DesktopApi = {
|
||||
checkForUpdates?: () => Promise<UpdateInfo>;
|
||||
downloadUpdate?: (onProgress?: (progress: UpdateProgress) => void) => Promise<void>;
|
||||
restartToUpdate?: () => Promise<void>;
|
||||
openExternal?: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
|
||||
export const isDesktopRuntime = (): boolean =>
|
||||
|
||||
@@ -280,6 +280,7 @@ interface ConfigStore {
|
||||
currentProviderId: string;
|
||||
currentModelId: string;
|
||||
currentAgentName: string | undefined;
|
||||
selectedProviderId: string;
|
||||
agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } };
|
||||
defaultProviders: { [key: string]: string };
|
||||
isConnected: boolean;
|
||||
@@ -294,6 +295,7 @@ interface ConfigStore {
|
||||
setProvider: (providerId: string) => void;
|
||||
setModel: (modelId: string) => void;
|
||||
setAgent: (agentName: string | undefined) => void;
|
||||
setSelectedProvider: (providerId: string) => void;
|
||||
setSettingsDefaultModel: (model: string | undefined) => void;
|
||||
setSettingsDefaultAgent: (agent: string | undefined) => void;
|
||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
||||
@@ -325,6 +327,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
currentProviderId: "",
|
||||
currentModelId: "",
|
||||
currentAgentName: undefined,
|
||||
selectedProviderId: "",
|
||||
agentModelSelections: {},
|
||||
defaultProviders: {},
|
||||
isConnected: false,
|
||||
@@ -392,6 +395,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
set({
|
||||
currentProviderId: providerId,
|
||||
currentModelId: newModelId,
|
||||
selectedProviderId: providerId,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -400,6 +404,10 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
set({ currentModelId: modelId });
|
||||
},
|
||||
|
||||
setSelectedProvider: (providerId: string) => {
|
||||
set({ selectedProviderId: providerId });
|
||||
},
|
||||
|
||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => {
|
||||
set((state) => ({
|
||||
agentModelSelections: {
|
||||
@@ -620,6 +628,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
set({
|
||||
currentProviderId: agent.model!.providerID,
|
||||
currentModelId: agent.model!.modelID,
|
||||
selectedProviderId: agent.model!.providerID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand } from './opencodeConfig';
|
||||
import { removeProviderAuth } from './opencodeAuth';
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: string;
|
||||
@@ -807,6 +808,36 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:provider/auth:delete': {
|
||||
const { providerId } = (payload || {}) as { providerId?: string };
|
||||
if (!providerId) {
|
||||
return { id, type, success: false, error: 'Provider ID is required' };
|
||||
}
|
||||
try {
|
||||
const removed = removeProviderAuth(providerId);
|
||||
if (removed) {
|
||||
await ctx?.manager?.restart();
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
removed,
|
||||
requiresReload: removed,
|
||||
message: removed
|
||||
? `Provider ${providerId} disconnected successfully. Reloading interface…`
|
||||
: `Provider ${providerId} was not configured.`,
|
||||
reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { id, type, success: false, error: `Unknown message type: ${type}` };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
|
||||
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
|
||||
|
||||
type AuthEntry = Record<string, unknown>;
|
||||
type AuthFile = Record<string, AuthEntry>;
|
||||
|
||||
const readAuthFile = (): AuthFile => {
|
||||
if (!fs.existsSync(AUTH_FILE)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(AUTH_FILE, 'utf8');
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(trimmed) as AuthFile;
|
||||
} catch (error) {
|
||||
console.error('Failed to read auth file:', error);
|
||||
throw new Error('Failed to read OpenCode auth configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const writeAuthFile = (auth: AuthFile): void => {
|
||||
try {
|
||||
if (!fs.existsSync(OPENCODE_DATA_DIR)) {
|
||||
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(AUTH_FILE)) {
|
||||
const backupFile = `${AUTH_FILE}.openchamber.backup`;
|
||||
fs.copyFileSync(AUTH_FILE, backupFile);
|
||||
}
|
||||
|
||||
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error('Failed to write auth file:', error);
|
||||
throw new Error('Failed to write OpenCode auth configuration');
|
||||
}
|
||||
};
|
||||
|
||||
export const removeProviderAuth = (providerId: string): boolean => {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
throw new Error('Provider ID is required');
|
||||
}
|
||||
|
||||
const auth = readAuthFile();
|
||||
|
||||
if (!auth[providerId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete auth[providerId];
|
||||
writeAuthFile(auth);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getProviderAuth = (providerId: string): AuthEntry | null => {
|
||||
const auth = readAuthFile();
|
||||
return auth[providerId] || null;
|
||||
};
|
||||
|
||||
export const listProviderAuths = (): string[] => {
|
||||
const auth = readAuthFile();
|
||||
return Object.keys(auth);
|
||||
};
|
||||
|
||||
export { AUTH_FILE, OPENCODE_DATA_DIR };
|
||||
@@ -428,6 +428,19 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
// Handle provider auth deletion: DELETE /api/provider/:providerId/auth
|
||||
const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/);
|
||||
if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') {
|
||||
const providerId = decodeURIComponent(providerAuthMatch[1]);
|
||||
try {
|
||||
const data = await sendBridgeMessage('api:provider/auth:delete', { providerId });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -2307,6 +2307,39 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
let authLibrary = null;
|
||||
const getAuthLibrary = async () => {
|
||||
if (!authLibrary) {
|
||||
authLibrary = await import('./lib/opencode-auth.js');
|
||||
}
|
||||
return authLibrary;
|
||||
};
|
||||
|
||||
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
if (!providerId) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
|
||||
const { removeProviderAuth } = await getAuthLibrary();
|
||||
const removed = removeProviderAuth(providerId);
|
||||
|
||||
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
removed,
|
||||
requiresReload: true,
|
||||
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect provider:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
|
||||
}
|
||||
});
|
||||
|
||||
let gitLibraries = null;
|
||||
const getGitLibraries = async () => {
|
||||
if (!gitLibraries) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
|
||||
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
|
||||
|
||||
function readAuthFile() {
|
||||
if (!fs.existsSync(AUTH_FILE)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(AUTH_FILE, 'utf8');
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.error('Failed to read auth file:', error);
|
||||
throw new Error('Failed to read OpenCode auth configuration');
|
||||
}
|
||||
}
|
||||
|
||||
function writeAuthFile(auth) {
|
||||
try {
|
||||
if (!fs.existsSync(OPENCODE_DATA_DIR)) {
|
||||
fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(AUTH_FILE)) {
|
||||
const backupFile = `${AUTH_FILE}.openchamber.backup`;
|
||||
fs.copyFileSync(AUTH_FILE, backupFile);
|
||||
console.log(`Created auth backup: ${backupFile}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8');
|
||||
console.log('Successfully wrote auth file');
|
||||
} catch (error) {
|
||||
console.error('Failed to write auth file:', error);
|
||||
throw new Error('Failed to write OpenCode auth configuration');
|
||||
}
|
||||
}
|
||||
|
||||
function removeProviderAuth(providerId) {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
throw new Error('Provider ID is required');
|
||||
}
|
||||
|
||||
const auth = readAuthFile();
|
||||
|
||||
if (!auth[providerId]) {
|
||||
console.log(`Provider ${providerId} not found in auth file, nothing to remove`);
|
||||
return false;
|
||||
}
|
||||
|
||||
delete auth[providerId];
|
||||
writeAuthFile(auth);
|
||||
console.log(`Removed provider auth: ${providerId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getProviderAuth(providerId) {
|
||||
const auth = readAuthFile();
|
||||
return auth[providerId] || null;
|
||||
}
|
||||
|
||||
function listProviderAuths() {
|
||||
const auth = readAuthFile();
|
||||
return Object.keys(auth);
|
||||
}
|
||||
|
||||
export {
|
||||
readAuthFile,
|
||||
writeAuthFile,
|
||||
removeProviderAuth,
|
||||
getProviderAuth,
|
||||
listProviderAuths,
|
||||
AUTH_FILE,
|
||||
OPENCODE_DATA_DIR
|
||||
};
|
||||
Reference in New Issue
Block a user