mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
feat: implement custom provider management with CRUD support, registration schema updates, and a dedicated UI form.
This commit is contained in:
@@ -5,6 +5,7 @@ Lives in server/ because it coordinates auth/ flows with vault/ storage and audi
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Self
|
||||
@@ -40,6 +41,7 @@ from authsome.errors import (
|
||||
CredentialMissingError,
|
||||
InvalidProviderSchemaError,
|
||||
OperationNotAllowedError,
|
||||
ProviderNotFoundError,
|
||||
RefreshFailedError,
|
||||
TokenExpiredError,
|
||||
UnsupportedFlowError,
|
||||
@@ -172,6 +174,28 @@ class CredentialService:
|
||||
)
|
||||
logger.info("Registered provider: {}", definition.name)
|
||||
|
||||
async def update_provider(self, provider: str, definition: ProviderDefinition) -> None:
|
||||
"""Update an existing custom provider definition."""
|
||||
self._require_admin("update", "update requires an admin principal", provider)
|
||||
if provider != definition.name:
|
||||
raise InvalidProviderSchemaError(
|
||||
f"Provider name '{definition.name}' must match route provider '{provider}'",
|
||||
provider=provider,
|
||||
)
|
||||
if not await self.is_custom_provider(provider):
|
||||
raise ProviderNotFoundError(provider)
|
||||
self._validate_provider(definition)
|
||||
await self._providers.save_custom(definition, force=True)
|
||||
audit.emit_event(
|
||||
"provider.updated",
|
||||
provider=definition.name,
|
||||
identity=self._identity,
|
||||
principal_id=self._principal_id,
|
||||
status="success",
|
||||
auth_type=definition.auth_type.value if definition.auth_type else None,
|
||||
)
|
||||
logger.info("Updated provider: {}", definition.name)
|
||||
|
||||
def _require_admin(self, operation: str, message: str, provider: str) -> None:
|
||||
"""Allow an operation only for admin principals."""
|
||||
if self._principal_role == PrincipalRole.ADMIN:
|
||||
@@ -180,20 +204,85 @@ class CredentialService:
|
||||
|
||||
def _validate_provider(self, definition: ProviderDefinition) -> None:
|
||||
validate_provider_definition(definition)
|
||||
self._validate_api_targets(definition.api_urls(), "api_url", definition.name)
|
||||
self._validate_optional_url(definition.docs_url, "docs_url", definition.name)
|
||||
if definition.oauth:
|
||||
for field_name in ("authorization_url", "token_url"):
|
||||
for field_name in (
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
"revocation_url",
|
||||
"device_authorization_url",
|
||||
"base_url",
|
||||
):
|
||||
url = getattr(definition.oauth, field_name, None)
|
||||
if url:
|
||||
self._validate_url(url, field_name, definition.name)
|
||||
self._validate_optional_url(url, field_name, definition.name, allow_base_url_template=True)
|
||||
if definition.registration:
|
||||
self._validate_optional_url(
|
||||
definition.registration.registration_endpoint,
|
||||
"registration.registration_endpoint",
|
||||
definition.name,
|
||||
)
|
||||
if definition.browser:
|
||||
self._validate_optional_url(definition.browser.entry_url, "browser.entry_url", definition.name)
|
||||
self._validate_optional_url(definition.browser.validate_url, "browser.validate_url", definition.name)
|
||||
|
||||
@staticmethod
|
||||
def _validate_url(url: str, field_name: str, provider_name: str) -> None:
|
||||
if "{base_url}" in url:
|
||||
def _validate_optional_url(
|
||||
url: str | None,
|
||||
field_name: str,
|
||||
provider_name: str,
|
||||
*,
|
||||
allow_base_url_template: bool = False,
|
||||
) -> None:
|
||||
if not url:
|
||||
return
|
||||
CredentialService._validate_url(url, field_name, provider_name, allow_base_url_template=allow_base_url_template)
|
||||
|
||||
@staticmethod
|
||||
def _validate_url(
|
||||
url: str,
|
||||
field_name: str,
|
||||
provider_name: str,
|
||||
*,
|
||||
allow_base_url_template: bool = False,
|
||||
) -> None:
|
||||
if allow_base_url_template and "{base_url}" in url:
|
||||
return
|
||||
if "{base_url}" in url:
|
||||
raise InvalidProviderSchemaError(
|
||||
f"Invalid URL for '{field_name}': {url}",
|
||||
provider=provider_name,
|
||||
)
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise InvalidProviderSchemaError(f"Invalid URL for '{field_name}': {url}", provider=provider_name)
|
||||
|
||||
@staticmethod
|
||||
def _validate_api_targets(targets: tuple[str, ...], field_name: str, provider_name: str) -> None:
|
||||
for target in targets:
|
||||
cleaned = target.strip()
|
||||
if not cleaned or any(char.isspace() for char in cleaned):
|
||||
raise InvalidProviderSchemaError(
|
||||
f"Invalid API target for '{field_name}': {target}",
|
||||
provider=provider_name,
|
||||
)
|
||||
if cleaned.startswith("regex:"):
|
||||
try:
|
||||
re.compile(cleaned.removeprefix("regex:"))
|
||||
except re.error as exc:
|
||||
raise InvalidProviderSchemaError(
|
||||
f"Invalid API target for '{field_name}': {target}",
|
||||
provider=provider_name,
|
||||
) from exc
|
||||
continue
|
||||
parsed = urlparse(cleaned if "://" in cleaned else f"https://{cleaned}")
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise InvalidProviderSchemaError(
|
||||
f"Invalid API target for '{field_name}': {target}",
|
||||
provider=provider_name,
|
||||
)
|
||||
|
||||
# ── Connection operations ─────────────────────────────────────────────
|
||||
|
||||
async def list_connections(self) -> list[dict[str, Any]]:
|
||||
@@ -904,6 +993,13 @@ class CredentialService:
|
||||
await self.revoke(provider)
|
||||
if await self.is_custom_provider(provider):
|
||||
await self._providers.delete_custom(provider)
|
||||
audit.emit_event(
|
||||
"provider.deleted",
|
||||
provider=provider,
|
||||
identity=self._identity,
|
||||
principal_id=self._principal_id,
|
||||
status="success",
|
||||
)
|
||||
logger.info("Removed local provider definition: {}", provider)
|
||||
else:
|
||||
logger.info("Revoked bundled provider: {} (definition kept)", provider)
|
||||
|
||||
@@ -179,14 +179,6 @@ async def get_protected_auth_service(
|
||||
return _build_service(request, ownership)
|
||||
|
||||
|
||||
async def get_admin_auth_service(
|
||||
auth: CredentialService = Depends(get_protected_auth_service),
|
||||
) -> CredentialService:
|
||||
if auth.principal_role != PrincipalRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||
return auth
|
||||
|
||||
|
||||
async def get_daemon_or_browser_auth_service(request: Request) -> CredentialService:
|
||||
"""Resolve auth from PoP headers or an existing browser dashboard session."""
|
||||
if request.headers.get("Authorization"):
|
||||
|
||||
@@ -9,7 +9,6 @@ from authsome.server.analytics import capture_event
|
||||
from authsome.server.credential_service import CredentialService
|
||||
from authsome.server.routes._deps import (
|
||||
build_auth_service,
|
||||
get_admin_auth_service,
|
||||
get_daemon_or_browser_auth_service,
|
||||
get_protected_auth_service,
|
||||
get_server_base_url,
|
||||
@@ -168,12 +167,14 @@ async def update_provider_configuration(
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def register_provider(body: dict, auth: CredentialService = Depends(get_admin_auth_service)):
|
||||
async def register_provider(body: dict, auth: CredentialService = Depends(get_daemon_or_browser_auth_service)):
|
||||
if auth.principal_role != PrincipalRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||
definition_payload = body.get("definition", body)
|
||||
definition = ProviderDefinition.model_validate(definition_payload)
|
||||
await auth.register_provider(definition, force=bool(body.get("force", False)))
|
||||
capture_event(
|
||||
auth.require_identity(),
|
||||
_actor(auth),
|
||||
"provider registered",
|
||||
{
|
||||
"provider": definition.name,
|
||||
@@ -184,11 +185,39 @@ async def register_provider(body: dict, auth: CredentialService = Depends(get_ad
|
||||
return {"status": "ok", "provider": definition.name}
|
||||
|
||||
|
||||
@router.put("/{provider}")
|
||||
async def update_provider(
|
||||
provider: str,
|
||||
body: dict,
|
||||
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
|
||||
):
|
||||
if auth.principal_role != PrincipalRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||
definition_payload = body.get("definition", body)
|
||||
definition = ProviderDefinition.model_validate(definition_payload)
|
||||
await auth.update_provider(provider, definition)
|
||||
capture_event(
|
||||
_actor(auth),
|
||||
"provider updated",
|
||||
{
|
||||
"provider": definition.name,
|
||||
"auth_type": definition.auth_type.value if definition.auth_type else None,
|
||||
"principal_id": auth.principal_id,
|
||||
},
|
||||
)
|
||||
return {"status": "ok", "provider": definition.name}
|
||||
|
||||
|
||||
@router.delete("/{provider}")
|
||||
async def delete_provider(provider: str, auth: CredentialService = Depends(get_admin_auth_service)):
|
||||
async def delete_provider(
|
||||
provider: str,
|
||||
auth: CredentialService = Depends(get_daemon_or_browser_auth_service),
|
||||
):
|
||||
if auth.principal_role != PrincipalRole.ADMIN:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||
await auth.remove(provider)
|
||||
capture_event(
|
||||
auth.require_identity(),
|
||||
_actor(auth),
|
||||
"provider deleted",
|
||||
{
|
||||
"provider": provider,
|
||||
|
||||
@@ -117,3 +117,119 @@ def test_first_principal_admin_can_register_provider(monkeypatch, tmp_path: Path
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_browser_session_admin_can_register_provider(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
|
||||
payload = {
|
||||
"definition": {
|
||||
"name": "custom-api",
|
||||
"display_name": "Custom API",
|
||||
"auth_type": "api_key",
|
||||
"flow": "api_key",
|
||||
"api_key": {"header_name": "Authorization"},
|
||||
}
|
||||
}
|
||||
|
||||
with create_server_test_client() as client:
|
||||
registered = client.post(
|
||||
"/api/auth/register",
|
||||
data={"email": "admin@example.com", "password": "password-1", "next": "/providers"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
response = client.post("/api/providers", json=payload)
|
||||
|
||||
assert registered.status_code == status.HTTP_303_SEE_OTHER
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"status": "ok", "provider": "custom-api"}
|
||||
|
||||
|
||||
def test_admin_can_update_custom_provider(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
|
||||
create_payload = {
|
||||
"definition": {
|
||||
"name": "custom-api",
|
||||
"display_name": "Custom API",
|
||||
"auth_type": "api_key",
|
||||
"flow": "api_key",
|
||||
"api_url": "api.example.com",
|
||||
"api_key": {"header_name": "Authorization", "header_prefix": "Bearer"},
|
||||
}
|
||||
}
|
||||
update_payload = {
|
||||
"definition": {
|
||||
"name": "custom-api",
|
||||
"display_name": "Updated API",
|
||||
"auth_type": "api_key",
|
||||
"flow": "api_key",
|
||||
"api_url": "https://api.example.com/v2",
|
||||
"api_key": {"header_name": "x-api-key", "header_prefix": ""},
|
||||
}
|
||||
}
|
||||
create_body = json.dumps(create_payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
update_body = json.dumps(update_payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
|
||||
with create_server_test_client() as client:
|
||||
_register_identity(client, tmp_path, "steady-wisely-boldly-0042")
|
||||
created = client.post(
|
||||
"/api/providers",
|
||||
content=create_body,
|
||||
headers={
|
||||
**_auth_header(tmp_path, "POST", "/api/providers", body=create_body),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
response = client.put(
|
||||
"/api/providers/custom-api",
|
||||
content=update_body,
|
||||
headers={
|
||||
**_auth_header(tmp_path, "PUT", "/api/providers/custom-api", body=update_body),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
fetched = client.get(
|
||||
"/api/providers/custom-api",
|
||||
headers=_auth_header(tmp_path, "GET", "/api/providers/custom-api"),
|
||||
)
|
||||
|
||||
assert created.status_code == status.HTTP_200_OK
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"status": "ok", "provider": "custom-api"}
|
||||
assert fetched.json()["display_name"] == "Updated API"
|
||||
assert fetched.json()["api_key"]["header_name"] == "x-api-key"
|
||||
|
||||
|
||||
def test_provider_registration_rejects_invalid_url_fields(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
|
||||
payload = {
|
||||
"definition": {
|
||||
"name": "custom-api",
|
||||
"display_name": "Custom API",
|
||||
"auth_type": "api_key",
|
||||
"flow": "api_key",
|
||||
"api_url": "https://api.example.com",
|
||||
"docs_url": "not a url",
|
||||
"api_key": {"header_name": "Authorization"},
|
||||
}
|
||||
}
|
||||
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
|
||||
with create_server_test_client() as client:
|
||||
_register_identity(client, tmp_path, "steady-wisely-boldly-0042")
|
||||
response = client.post(
|
||||
"/api/providers",
|
||||
content=body,
|
||||
headers={
|
||||
**_auth_header(tmp_path, "POST", "/api/providers", body=body),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
fetched = client.get(
|
||||
"/api/providers/custom-api",
|
||||
headers=_auth_header(tmp_path, "GET", "/api/providers/custom-api"),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "InvalidProviderSchemaError"
|
||||
assert "docs_url" in response.json()["message"]
|
||||
assert fetched.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -6,7 +6,13 @@ import { ProvidersView } from "@/components/dashboard/provider-views";
|
||||
import { fetchDashboard } from "@/lib/authsome-api";
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const { data } = useSWR("authsome-dashboard", fetchDashboard);
|
||||
const { data, mutate } = useSWR("authsome-dashboard", fetchDashboard);
|
||||
if (!data) return null;
|
||||
return <ProvidersView providers={data.providers} />;
|
||||
return (
|
||||
<ProvidersView
|
||||
isAdmin={data.account.isAdmin}
|
||||
onRefresh={() => void mutate()}
|
||||
providers={data.providers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,9 @@ function ActiveView({
|
||||
onRefresh: () => void;
|
||||
view: View;
|
||||
}) {
|
||||
if (view === "providers") return <ProvidersView providers={data.providers} />;
|
||||
if (view === "providers") {
|
||||
return <ProvidersView isAdmin={data.account.isAdmin} onRefresh={onRefresh} providers={data.providers} />;
|
||||
}
|
||||
if (view === "connections") {
|
||||
return (
|
||||
<ConnectionsView
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { LogIn } from "lucide-react";
|
||||
import { LogIn, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
|
||||
import { CustomProviderDialog } from "@/components/dashboard/custom-provider-form";
|
||||
import {
|
||||
INTERACTIVE_CARD_CLASS,
|
||||
ProviderLogo,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ProviderView } from "@/lib/authsome-api";
|
||||
import { ProviderView, deleteCustomProvider } from "@/lib/authsome-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ProviderSummary({ provider }: { provider: ProviderView }) {
|
||||
@@ -48,9 +49,19 @@ export function ProviderSummary({ provider }: { provider: ProviderView }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProvidersView({ providers }: { providers: ProviderView[] }) {
|
||||
export function ProvidersView({
|
||||
isAdmin = false,
|
||||
onRefresh,
|
||||
providers,
|
||||
}: {
|
||||
isAdmin?: boolean;
|
||||
onRefresh?: () => void;
|
||||
providers: ProviderView[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [dialogProvider, setDialogProvider] = useState<NamedConnectionProvider | null>(null);
|
||||
const [formState, setFormState] = useState<{ mode: "create" | "edit"; provider?: ProviderView } | null>(null);
|
||||
const [deleteProvider, setDeleteProvider] = useState<ProviderView | null>(null);
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
@@ -70,11 +81,26 @@ export function ProvidersView({ providers }: { providers: ProviderView[] }) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<SectionHeader description="Configure providers and start browser login flows." title="Providers" />
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<SectionHeader description="Configure providers and start browser login flows." title="Providers" />
|
||||
{isAdmin ? (
|
||||
<Button onClick={() => setFormState({ mode: "create" })} type="button">
|
||||
<Plus />
|
||||
New provider
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<SearchInput onChange={setQuery} placeholder="Search providers..." value={query} />
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredProviders.map((provider) => (
|
||||
<ProviderCard key={provider.name} onNamedLogin={() => setDialogProvider(provider)} provider={provider} />
|
||||
<ProviderCard
|
||||
isAdmin={isAdmin}
|
||||
key={provider.name}
|
||||
onDelete={() => setDeleteProvider(provider)}
|
||||
onEdit={() => setFormState({ mode: "edit", provider })}
|
||||
onNamedLogin={() => setDialogProvider(provider)}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!filteredProviders.length ? (
|
||||
@@ -89,6 +115,23 @@ export function ProvidersView({ providers }: { providers: ProviderView[] }) {
|
||||
)
|
||||
) : null}
|
||||
<NamedConnectionDialog onOpenChange={setDialogProvider} provider={dialogProvider} />
|
||||
<CustomProviderDialog
|
||||
key={`${formState?.mode || "create"}:${formState?.provider?.name || "new"}`}
|
||||
mode={formState?.mode || "create"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFormState(null);
|
||||
}}
|
||||
onSaved={() => onRefresh?.()}
|
||||
open={Boolean(formState)}
|
||||
provider={formState?.provider?.definition || null}
|
||||
/>
|
||||
<DeleteCustomProviderDialog
|
||||
onDeleted={() => onRefresh?.()}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteProvider(null);
|
||||
}}
|
||||
provider={deleteProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,8 +140,21 @@ function providerSortRank(provider: ProviderView): number {
|
||||
return provider.status === "available" ? 1 : 0;
|
||||
}
|
||||
|
||||
function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; provider: ProviderView }) {
|
||||
function ProviderCard({
|
||||
isAdmin,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onNamedLogin,
|
||||
provider,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onNamedLogin: () => void;
|
||||
provider: ProviderView;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const canManage = isAdmin && provider.source === "custom";
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -108,7 +164,39 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<ProviderLogo className="size-10 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
|
||||
<StatusBadge status={provider.status} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{canManage ? (
|
||||
<>
|
||||
<Button
|
||||
aria-label={`Edit ${provider.displayName}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`Delete ${provider.displayName}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<StatusBadge status={provider.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<CardTitle className="text-base leading-tight">{provider.displayName}</CardTitle>
|
||||
@@ -156,6 +244,58 @@ function ProviderCard({ onNamedLogin, provider }: { onNamedLogin: () => void; pr
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteCustomProviderDialog({
|
||||
onDeleted,
|
||||
onOpenChange,
|
||||
provider,
|
||||
}: {
|
||||
onDeleted: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider: ProviderView | null;
|
||||
}) {
|
||||
const [working, setWorking] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function deleteProviderDefinition() {
|
||||
if (!provider) return;
|
||||
setWorking(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await deleteCustomProvider(provider.name);
|
||||
onDeleted();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Provider could not be deleted.");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={Boolean(provider)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete custom provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
{provider?.connectionCount
|
||||
? `${provider.displayName} has ${provider.connectionCount} connection${provider.connectionCount === 1 ? "" : "s"}. Deleting it will revoke those credentials first.`
|
||||
: `${provider?.displayName || "This provider"} will be removed from custom providers.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{message ? <div className="text-sm text-destructive">{message}</div> : null}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={working} onClick={() => void deleteProviderDefinition()} type="button" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export type NamedConnectionProvider = Pick<ProviderView, "displayName" | "name">;
|
||||
|
||||
export function NamedConnectionDialog({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({ className, ...props }: React.ComponentProps<"select">) {
|
||||
return (
|
||||
<select
|
||||
data-slot="select"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 py-1 text-base transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Select }
|
||||
@@ -8,6 +8,7 @@ export type DashboardStats = {
|
||||
export type ProviderView = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
definition: ProviderResponse;
|
||||
authType: "oauth2" | "api_key" | string;
|
||||
authTypeLabel: string;
|
||||
apiUrl: string;
|
||||
@@ -134,17 +135,73 @@ export type ProviderResponse = {
|
||||
display_name?: string;
|
||||
logo?: string | null;
|
||||
description?: string | null;
|
||||
type?: "app" | "llm" | "mcp" | "browser" | string | null;
|
||||
auth_type?: string;
|
||||
flow?: string;
|
||||
api_url?: string | string[] | null;
|
||||
oauth?: {
|
||||
authorization_url?: string;
|
||||
token_url?: string;
|
||||
revocation_url?: string | null;
|
||||
device_authorization_url?: string | null;
|
||||
device_token_request?: "oauth2_form" | "json";
|
||||
scopes?: string[];
|
||||
authorization_params?: Record<string, string>;
|
||||
pkce?: boolean;
|
||||
supports_device_code?: boolean;
|
||||
supports_dcr?: boolean;
|
||||
base_url?: string | null;
|
||||
authorization_method?: "body" | "basic";
|
||||
} | null;
|
||||
registration?: {
|
||||
registration_endpoint?: string | null;
|
||||
} | null;
|
||||
api_key?: {
|
||||
header_name?: string;
|
||||
header_prefix?: string | null;
|
||||
key_pattern?: string | null;
|
||||
key_pattern_hint?: string | null;
|
||||
} | null;
|
||||
browser?: {
|
||||
entry_url?: string;
|
||||
domains?: string[];
|
||||
auth_cookies?: string[];
|
||||
validate_url?: string | null;
|
||||
extra_headers?: Record<string, string>;
|
||||
ttl_hours?: number;
|
||||
ttl_from_cookie?: string | null;
|
||||
extract?: Array<{
|
||||
cookie: string;
|
||||
header: string;
|
||||
prefix?: string;
|
||||
}>;
|
||||
} | null;
|
||||
export?: Record<string, string> | { env?: Record<string, string> } | null;
|
||||
metadata?: {
|
||||
description?: string;
|
||||
};
|
||||
docs_url?: string | null;
|
||||
};
|
||||
|
||||
export type ProviderDefinitionPayload = {
|
||||
schema_version?: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
logo?: string | null;
|
||||
description?: string | null;
|
||||
type?: "app" | "llm" | "mcp" | "browser" | null;
|
||||
auth_type: "oauth2" | "api_key" | "browser";
|
||||
flow: "pkce" | "device_code" | "dcr_pkce" | "api_key" | "browser";
|
||||
oauth?: NonNullable<ProviderResponse["oauth"]> | null;
|
||||
registration?: NonNullable<ProviderResponse["registration"]> | null;
|
||||
api_key?: NonNullable<ProviderResponse["api_key"]> | null;
|
||||
browser?: NonNullable<ProviderResponse["browser"]> | null;
|
||||
export?: ProviderResponse["export"];
|
||||
docs_url?: string | null;
|
||||
api_url?: string | string[] | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ProviderClientDetail = {
|
||||
client_id: string | null;
|
||||
client_secret: string | null;
|
||||
@@ -378,6 +435,7 @@ function providerView(
|
||||
return {
|
||||
name: provider.name,
|
||||
displayName,
|
||||
definition: provider,
|
||||
authType: provider.auth_type || "provider",
|
||||
authTypeLabel: authTypeLabel(provider.auth_type),
|
||||
apiUrl: providerApiUrl(provider),
|
||||
@@ -626,6 +684,32 @@ export async function updateProviderConfiguration(
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCustomProvider(
|
||||
definition: ProviderDefinitionPayload,
|
||||
): Promise<{ status: "ok"; provider: string }> {
|
||||
return sendJson("/api/providers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ definition }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCustomProvider(
|
||||
provider: string,
|
||||
definition: ProviderDefinitionPayload,
|
||||
): Promise<{ status: "ok"; provider: string }> {
|
||||
return sendJson(`/api/providers/${encodeURIComponent(provider)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ definition }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCustomProvider(provider: string): Promise<{ status: "ok"; provider: string }> {
|
||||
return sendJson(`/api/providers/${encodeURIComponent(provider)}`, {
|
||||
method: "DELETE",
|
||||
body: "{}",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchConnectionDetail(
|
||||
provider: string,
|
||||
connection: string,
|
||||
|
||||
Reference in New Issue
Block a user