feat(admin): add OAuth admin page for managing OAuth applications

This replaces the script-based setup for OAuth clients with a web-based
admin interface. The new page allows admins to:
- List all OAuth applications with search and pagination
- Create new OAuth applications with custom scopes and redirect URIs
- Enable/disable OAuth applications
- Regenerate client secrets
- Delete OAuth applications

Backend changes:
- Add admin data layer functions in oauth.py for CRUD operations
- Add new oauth_admin_routes.py with protected admin endpoints
- Register routes in rest_api.py

Frontend changes:
- Add OAuth link to admin sidebar
- Add OAuthApplication types and API client methods
- Create OAuth admin page with list, create, and manage functionality
This commit is contained in:
Claude
2026-01-08 05:40:58 +00:00
parent fc8434fb30
commit fccda4e4d9
10 changed files with 1510 additions and 1 deletions

View File

@@ -0,0 +1,343 @@
"""
OAuth Application Admin Routes
Provides admin-only endpoints for managing OAuth applications:
- List all OAuth applications
- Create new OAuth applications
- Update OAuth applications
- Delete OAuth applications
- Regenerate client secrets
"""
import logging
from typing import Optional
from autogpt_libs.auth import get_user_id, requires_admin_user
from fastapi import APIRouter, Body, HTTPException, Query, Security, status
from prisma.enums import APIKeyPermission
from pydantic import BaseModel, Field
from backend.data.auth.oauth import (
OAuthApplicationCreationResult,
OAuthApplicationInfo,
admin_update_oauth_application,
create_oauth_application,
delete_oauth_application,
get_oauth_application_by_id,
list_all_oauth_applications,
regenerate_client_secret,
)
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/admin",
tags=["oauth", "admin"],
dependencies=[Security(requires_admin_user)],
)
# ============================================================================
# Request/Response Models
# ============================================================================
class CreateOAuthAppRequest(BaseModel):
"""Request to create a new OAuth application"""
name: str = Field(description="Application name")
description: Optional[str] = Field(None, description="Application description")
redirect_uris: list[str] = Field(description="Allowed redirect URIs")
scopes: list[str] = Field(
description="List of scopes (e.g., EXECUTE_GRAPH, READ_GRAPH)"
)
grant_types: Optional[list[str]] = Field(
None,
description="Grant types (default: authorization_code, refresh_token)",
)
owner_id: str = Field(description="User ID who will own this application")
class UpdateOAuthAppRequest(BaseModel):
"""Request to update an OAuth application"""
name: Optional[str] = Field(None, description="Application name")
description: Optional[str] = Field(None, description="Application description")
redirect_uris: Optional[list[str]] = Field(None, description="Allowed redirect URIs")
scopes: Optional[list[str]] = Field(None, description="List of scopes")
is_active: Optional[bool] = Field(None, description="Whether the app is active")
class OAuthAppsListResponse(BaseModel):
"""Response for listing OAuth applications"""
applications: list[OAuthApplicationInfo]
total: int
page: int
page_size: int
total_pages: int
class RegenerateSecretResponse(BaseModel):
"""Response when regenerating a client secret"""
client_secret: str = Field(
description="New plaintext client secret - shown only once"
)
# ============================================================================
# Admin Endpoints
# ============================================================================
@router.get(
"/apps",
response_model=OAuthAppsListResponse,
summary="List All OAuth Applications",
)
async def list_oauth_apps(
admin_user_id: str = Security(get_user_id),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(None, description="Search by name, client ID, or description"),
):
"""
List all OAuth applications in the system.
Admin-only endpoint. Returns paginated list of all OAuth applications
with their details (excluding client secrets).
"""
logger.info(f"Admin user {admin_user_id} is listing OAuth applications")
applications, total = await list_all_oauth_applications(
page=page,
page_size=page_size,
search=search,
)
total_pages = (total + page_size - 1) // page_size
return OAuthAppsListResponse(
applications=applications,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
)
@router.get(
"/apps/{app_id}",
response_model=OAuthApplicationInfo,
summary="Get OAuth Application Details",
)
async def get_oauth_app(
app_id: str,
admin_user_id: str = Security(get_user_id),
):
"""
Get details of a specific OAuth application.
Admin-only endpoint. Returns application details (excluding client secret).
"""
logger.info(f"Admin user {admin_user_id} is getting OAuth app {app_id}")
app = await get_oauth_application_by_id(app_id)
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth application not found",
)
return app
@router.post(
"/apps",
response_model=OAuthApplicationCreationResult,
summary="Create OAuth Application",
status_code=status.HTTP_201_CREATED,
)
async def create_oauth_app(
request: CreateOAuthAppRequest = Body(),
admin_user_id: str = Security(get_user_id),
):
"""
Create a new OAuth application.
Admin-only endpoint. Returns the created application including the
plaintext client secret (which is only shown once).
The client secret is hashed before storage and cannot be retrieved later.
If lost, a new secret must be generated using the regenerate endpoint.
"""
logger.info(
f"Admin user {admin_user_id} is creating OAuth app '{request.name}' "
f"for user {request.owner_id}"
)
# Validate scopes
try:
validated_scopes = [APIKeyPermission(s.strip()) for s in request.scopes if s.strip()]
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid scope: {e}",
)
if not validated_scopes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one scope is required",
)
# Validate redirect URIs
if not request.redirect_uris:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one redirect URI is required",
)
result = await create_oauth_application(
name=request.name,
description=request.description,
redirect_uris=request.redirect_uris,
scopes=validated_scopes,
owner_id=request.owner_id,
grant_types=request.grant_types,
)
logger.info(
f"Created OAuth app '{result.application.name}' "
f"(client_id: {result.application.client_id})"
)
return result
@router.patch(
"/apps/{app_id}",
response_model=OAuthApplicationInfo,
summary="Update OAuth Application",
)
async def update_oauth_app(
app_id: str,
request: UpdateOAuthAppRequest = Body(),
admin_user_id: str = Security(get_user_id),
):
"""
Update an OAuth application.
Admin-only endpoint. Can update name, description, redirect URIs,
scopes, and active status.
"""
logger.info(f"Admin user {admin_user_id} is updating OAuth app {app_id}")
# Validate scopes if provided
validated_scopes = None
if request.scopes is not None:
try:
validated_scopes = [
APIKeyPermission(s.strip()) for s in request.scopes if s.strip()
]
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid scope: {e}",
)
if not validated_scopes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one scope is required",
)
updated_app = await admin_update_oauth_application(
app_id=app_id,
name=request.name,
description=request.description,
redirect_uris=request.redirect_uris,
scopes=validated_scopes,
is_active=request.is_active,
)
if not updated_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth application not found",
)
action = "updated"
if request.is_active is not None:
action = "enabled" if request.is_active else "disabled"
logger.info(f"OAuth app {updated_app.name} (#{app_id}) {action}")
return updated_app
@router.delete(
"/apps/{app_id}",
summary="Delete OAuth Application",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_oauth_app(
app_id: str,
admin_user_id: str = Security(get_user_id),
):
"""
Delete an OAuth application.
Admin-only endpoint. This will also delete all associated authorization
codes, access tokens, and refresh tokens.
This action is irreversible.
"""
logger.info(f"Admin user {admin_user_id} is deleting OAuth app {app_id}")
deleted = await delete_oauth_application(app_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth application not found",
)
logger.info(f"Deleted OAuth app {app_id}")
return None
@router.post(
"/apps/{app_id}/regenerate-secret",
response_model=RegenerateSecretResponse,
summary="Regenerate Client Secret",
)
async def regenerate_oauth_secret(
app_id: str,
admin_user_id: str = Security(get_user_id),
):
"""
Regenerate the client secret for an OAuth application.
Admin-only endpoint. The old secret will be invalidated immediately.
Returns the new plaintext client secret (shown only once).
All existing tokens will continue to work, but new token requests
must use the new client secret.
"""
logger.info(
f"Admin user {admin_user_id} is regenerating secret for OAuth app {app_id}"
)
new_secret = await regenerate_client_secret(app_id)
if not new_secret:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth application not found",
)
logger.info(f"Regenerated client secret for OAuth app {app_id}")
return RegenerateSecretResponse(client_secret=new_secret)

View File

@@ -18,6 +18,7 @@ from prisma.errors import PrismaError
import backend.api.features.admin.credit_admin_routes
import backend.api.features.admin.execution_analytics_routes
import backend.api.features.admin.oauth_admin_routes
import backend.api.features.admin.store_admin_routes
import backend.api.features.builder
import backend.api.features.builder.routes
@@ -320,6 +321,11 @@ app.include_router(
tags=["oauth"],
prefix="/api/oauth",
)
app.include_router(
backend.api.features.admin.oauth_admin_routes.router,
tags=["v2", "admin", "oauth"],
prefix="/api/oauth",
)
app.mount("/external-api", external_api)

View File

@@ -870,3 +870,213 @@ async def cleanup_expired_oauth_tokens() -> dict[str, int]:
logger.info(f"Cleaned up {total} expired OAuth tokens: {deleted}")
return deleted
# ============================================================================
# Admin Functions for OAuth Application Management
# ============================================================================
def generate_client_id() -> str:
"""Generate a unique client ID"""
return f"agpt_client_{secrets.token_urlsafe(16)}"
def generate_client_secret() -> tuple[str, str, str]:
"""
Generate a client secret with its hash and salt.
Returns (plaintext_secret, hashed_secret, salt)
"""
# Generate a secure random secret (32 bytes = 256 bits of entropy)
plaintext = f"agpt_secret_{secrets.token_urlsafe(32)}"
# Hash using Scrypt (same as API keys)
hashed, salt = keysmith.hash_key(plaintext)
return plaintext, hashed, salt
class OAuthApplicationCreationResult(BaseModel):
"""Result of creating an OAuth application (includes plaintext secret)"""
application: OAuthApplicationInfo
client_secret_plaintext: str = Field(
description="Plaintext client secret - shown only once"
)
async def list_all_oauth_applications(
page: int = 1,
page_size: int = 20,
search: Optional[str] = None,
) -> tuple[list[OAuthApplicationInfo], int]:
"""
List all OAuth applications (admin function).
Returns a tuple of (applications, total_count).
"""
where_clause = {}
if search:
where_clause["OR"] = [
{"name": {"contains": search, "mode": "insensitive"}},
{"clientId": {"contains": search, "mode": "insensitive"}},
{"description": {"contains": search, "mode": "insensitive"}},
]
total = await PrismaOAuthApplication.prisma().count(where=where_clause)
apps = await PrismaOAuthApplication.prisma().find_many(
where=where_clause,
order={"createdAt": "desc"},
skip=(page - 1) * page_size,
take=page_size,
)
return [OAuthApplicationInfo.from_db(app) for app in apps], total
async def create_oauth_application(
name: str,
redirect_uris: list[str],
scopes: list[APIPermission],
owner_id: str,
description: Optional[str] = None,
grant_types: Optional[list[str]] = None,
) -> OAuthApplicationCreationResult:
"""
Create a new OAuth application.
Returns the created application info along with the plaintext client secret
(which is only available at creation time).
"""
if grant_types is None:
grant_types = ["authorization_code", "refresh_token"]
# Generate credentials
app_id = str(uuid.uuid4())
client_id = generate_client_id()
client_secret_plaintext, client_secret_hash, client_secret_salt = (
generate_client_secret()
)
# Create in database
app = await PrismaOAuthApplication.prisma().create(
data={
"id": app_id,
"name": name,
"description": description,
"clientId": client_id,
"clientSecret": client_secret_hash,
"clientSecretSalt": client_secret_salt,
"redirectUris": redirect_uris,
"grantTypes": grant_types,
"scopes": [s.value for s in scopes],
"ownerId": owner_id,
"isActive": True,
}
)
logger.info(f"Created OAuth application: {name} (#{app_id}) for user #{owner_id}")
return OAuthApplicationCreationResult(
application=OAuthApplicationInfo.from_db(app),
client_secret_plaintext=client_secret_plaintext,
)
async def admin_update_oauth_application(
app_id: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
redirect_uris: Optional[list[str]] = None,
scopes: Optional[list[APIPermission]] = None,
is_active: Optional[bool] = None,
logo_url: Optional[str] = None,
) -> Optional[OAuthApplicationInfo]:
"""
Update an OAuth application (admin function - can update any app).
Returns the updated app info, or None if app not found.
"""
from prisma.types import OAuthApplicationUpdateInput
app = await PrismaOAuthApplication.prisma().find_unique(where={"id": app_id})
if not app:
return None
patch: OAuthApplicationUpdateInput = {}
if name is not None:
patch["name"] = name
if description is not None:
patch["description"] = description
if redirect_uris is not None:
patch["redirectUris"] = redirect_uris
if scopes is not None:
patch["scopes"] = [s.value for s in scopes]
if is_active is not None:
patch["isActive"] = is_active
if logo_url is not None:
patch["logoUrl"] = logo_url
if not patch:
return OAuthApplicationInfo.from_db(app) # return unchanged
updated_app = await PrismaOAuthApplication.prisma().update(
where={"id": app_id},
data=patch,
)
return OAuthApplicationInfo.from_db(updated_app) if updated_app else None
async def delete_oauth_application(app_id: str) -> bool:
"""
Delete an OAuth application and all its associated tokens.
Returns True if the application was deleted, False if not found.
"""
app = await PrismaOAuthApplication.prisma().find_unique(where={"id": app_id})
if not app:
return False
# Delete associated tokens first (cascading deletes should handle this,
# but let's be explicit)
await PrismaOAuthAuthorizationCode.prisma().delete_many(
where={"applicationId": app_id}
)
await PrismaOAuthAccessToken.prisma().delete_many(where={"applicationId": app_id})
await PrismaOAuthRefreshToken.prisma().delete_many(where={"applicationId": app_id})
# Delete the application
await PrismaOAuthApplication.prisma().delete(where={"id": app_id})
logger.info(f"Deleted OAuth application: {app.name} (#{app_id})")
return True
async def regenerate_client_secret(app_id: str) -> Optional[str]:
"""
Regenerate the client secret for an OAuth application.
Returns the new plaintext client secret, or None if app not found.
"""
app = await PrismaOAuthApplication.prisma().find_unique(where={"id": app_id})
if not app:
return None
# Generate new credentials
client_secret_plaintext, client_secret_hash, client_secret_salt = (
generate_client_secret()
)
# Update in database
await PrismaOAuthApplication.prisma().update(
where={"id": app_id},
data={
"clientSecret": client_secret_hash,
"clientSecretSalt": client_secret_salt,
},
)
logger.info(f"Regenerated client secret for OAuth application: #{app_id}")
return client_secret_plaintext

View File

@@ -1,5 +1,5 @@
import { Sidebar } from "@/components/__legacy__/Sidebar";
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
import { Users, DollarSign, UserSearch, FileText, KeyRound } from "lucide-react";
import { IconSliders } from "@/components/__legacy__/ui/icons";
@@ -26,6 +26,11 @@ const sidebarLinkGroups = [
href: "/admin/execution-analytics",
icon: <FileText className="h-6 w-6" />,
},
{
text: "OAuth Applications",
href: "/admin/oauth",
icon: <KeyRound className="h-6 w-6" />,
},
{
text: "Admin User Management",
href: "/admin/settings",

View File

@@ -0,0 +1,64 @@
"use server";
import { revalidatePath } from "next/cache";
import BackendApi from "@/lib/autogpt-server-api";
import type {
OAuthAppsListResponse,
OAuthApplicationCreationResult,
OAuthApplication,
CreateOAuthAppRequest,
UpdateOAuthAppRequest,
RegenerateSecretResponse,
} from "@/lib/autogpt-server-api/types";
export async function getOAuthApps(
page: number = 1,
pageSize: number = 20,
search?: string,
): Promise<OAuthAppsListResponse> {
const api = new BackendApi();
return api.getOAuthApps({
page,
page_size: pageSize,
search,
});
}
export async function getOAuthApp(appId: string): Promise<OAuthApplication> {
const api = new BackendApi();
return api.getOAuthApp(appId);
}
export async function createOAuthApp(
request: CreateOAuthAppRequest,
): Promise<OAuthApplicationCreationResult> {
const api = new BackendApi();
const result = await api.createOAuthApp(request);
revalidatePath("/admin/oauth");
return result;
}
export async function updateOAuthApp(
appId: string,
request: UpdateOAuthAppRequest,
): Promise<OAuthApplication> {
const api = new BackendApi();
const result = await api.updateOAuthApp(appId, request);
revalidatePath("/admin/oauth");
return result;
}
export async function deleteOAuthApp(appId: string): Promise<void> {
const api = new BackendApi();
await api.deleteOAuthApp(appId);
revalidatePath("/admin/oauth");
}
export async function regenerateOAuthSecret(
appId: string,
): Promise<RegenerateSecretResponse> {
const api = new BackendApi();
const result = await api.regenerateOAuthSecret(appId);
revalidatePath("/admin/oauth");
return result;
}

View File

@@ -0,0 +1,343 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { Button } from "@/components/__legacy__/ui/button";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
import { Textarea } from "@/components/__legacy__/ui/textarea";
import { Checkbox } from "@/components/__legacy__/ui/checkbox";
import { Copy, Eye, EyeOff, Plus, X } from "lucide-react";
import { createOAuthApp } from "../actions";
import type { OAuthApplicationCreationResult } from "@/lib/autogpt-server-api/types";
const AVAILABLE_SCOPES = [
{ value: "EXECUTE_GRAPH", label: "Execute Graph", description: "Run agent graphs" },
{ value: "READ_GRAPH", label: "Read Graph", description: "Read agent graphs" },
{ value: "EXECUTE_BLOCK", label: "Execute Block", description: "Execute individual blocks" },
{ value: "READ_BLOCK", label: "Read Block", description: "Read block definitions" },
{ value: "READ_STORE", label: "Read Store", description: "Access the store" },
{ value: "USE_TOOLS", label: "Use Tools", description: "Use available tools" },
{ value: "MANAGE_INTEGRATIONS", label: "Manage Integrations", description: "Manage integrations" },
{ value: "READ_INTEGRATIONS", label: "Read Integrations", description: "Read integrations" },
{ value: "DELETE_INTEGRATIONS", label: "Delete Integrations", description: "Delete integrations" },
];
interface CreateOAuthAppDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateOAuthAppDialog({ open, onOpenChange }: CreateOAuthAppDialogProps) {
const [step, setStep] = useState<"form" | "success">("form");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<OAuthApplicationCreationResult | null>(null);
const [isSecretVisible, setIsSecretVisible] = useState(false);
// Form state
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [ownerId, setOwnerId] = useState("");
const [redirectUris, setRedirectUris] = useState<string[]>([""]);
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
const resetForm = () => {
setStep("form");
setIsLoading(false);
setError(null);
setResult(null);
setIsSecretVisible(false);
setName("");
setDescription("");
setOwnerId("");
setRedirectUris([""]);
setSelectedScopes([]);
};
const handleClose = () => {
resetForm();
onOpenChange(false);
};
const addRedirectUri = () => {
setRedirectUris([...redirectUris, ""]);
};
const removeRedirectUri = (index: number) => {
if (redirectUris.length > 1) {
setRedirectUris(redirectUris.filter((_, i) => i !== index));
}
};
const updateRedirectUri = (index: number, value: string) => {
const updated = [...redirectUris];
updated[index] = value;
setRedirectUris(updated);
};
const toggleScope = (scope: string) => {
setSelectedScopes((prev) =>
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
// Validation
const validUris = redirectUris.filter((uri) => uri.trim());
if (!name.trim()) {
setError("Name is required");
setIsLoading(false);
return;
}
if (!ownerId.trim()) {
setError("Owner ID is required");
setIsLoading(false);
return;
}
if (validUris.length === 0) {
setError("At least one redirect URI is required");
setIsLoading(false);
return;
}
if (selectedScopes.length === 0) {
setError("At least one scope is required");
setIsLoading(false);
return;
}
try {
const creationResult = await createOAuthApp({
name: name.trim(),
description: description.trim() || undefined,
owner_id: ownerId.trim(),
redirect_uris: validUris,
scopes: selectedScopes,
});
setResult(creationResult);
setStep("success");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create OAuth application");
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
{step === "form" ? (
<>
<DialogHeader>
<DialogTitle>Create OAuth Application</DialogTitle>
<DialogDescription>
Create a new OAuth application for third-party integrations.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Application Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Application"
/>
</div>
<div className="space-y-2">
<Label htmlFor="owner">Owner User ID *</Label>
<Input
id="owner"
value={ownerId}
onChange={(e) => setOwnerId(e.target.value)}
placeholder="User UUID"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this application does..."
rows={2}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Redirect URIs *</Label>
<Button type="button" variant="ghost" size="sm" onClick={addRedirectUri}>
<Plus className="mr-1 h-3 w-3" />
Add URI
</Button>
</div>
<div className="space-y-2">
{redirectUris.map((uri, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={uri}
onChange={(e) => updateRedirectUri(index, e.target.value)}
placeholder="https://example.com/callback"
/>
{redirectUris.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeRedirectUri(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>Scopes *</Label>
<div className="grid grid-cols-2 gap-2 p-4 border rounded-md bg-gray-50">
{AVAILABLE_SCOPES.map((scope) => (
<div
key={scope.value}
className="flex items-start space-x-2"
>
<Checkbox
id={scope.value}
checked={selectedScopes.includes(scope.value)}
onCheckedChange={() => toggleScope(scope.value)}
/>
<div className="leading-none">
<label
htmlFor={scope.value}
className="text-sm font-medium cursor-pointer"
>
{scope.label}
</label>
<p className="text-xs text-gray-500">{scope.description}</p>
</div>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Application"}
</Button>
</DialogFooter>
</form>
</>
) : (
<>
<DialogHeader>
<DialogTitle>OAuth Application Created</DialogTitle>
<DialogDescription>
Your OAuth application has been created successfully. Save the client secret now -
it will only be shown once!
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
<h4 className="font-medium text-green-800 mb-2">
{result?.application.name}
</h4>
<p className="text-sm text-green-700">
Application created successfully with ID: {result?.application.id}
</p>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-sm text-gray-500">Client ID</Label>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm bg-gray-100 px-3 py-2 rounded break-all">
{result?.application.client_id}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(result?.application.client_id || "")}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md space-y-2">
<Label className="text-sm font-medium text-yellow-800">
Client Secret (Save this now!)
</Label>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm bg-white px-3 py-2 rounded border break-all">
{isSecretVisible
? result?.client_secret_plaintext
: "••••••••••••••••••••••••••••••••"}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSecretVisible(!isSecretVisible)}
>
{isSecretVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(result?.client_secret_plaintext || "")}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-yellow-700">
This secret will only be shown once. Store it securely!
</p>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={handleClose}>Done</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,389 @@
"use client";
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { Button } from "@/components/__legacy__/ui/button";
import { Input } from "@/components/__legacy__/ui/input";
import { Badge } from "@/components/__legacy__/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/__legacy__/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/__legacy__/ui/dropdown-menu";
import { PaginationControls } from "@/components/__legacy__/ui/pagination-controls";
import { MoreHorizontal, Copy, Eye, EyeOff, Search, Plus, RefreshCw, Trash2, Edit } from "lucide-react";
import type { OAuthApplication, OAuthAppsListResponse } from "@/lib/autogpt-server-api/types";
import { deleteOAuthApp, regenerateOAuthSecret, updateOAuthApp } from "../actions";
import { CreateOAuthAppDialog } from "./CreateOAuthAppDialog";
import { useRouter, useSearchParams } from "next/navigation";
interface OAuthAppListProps {
initialData: OAuthAppsListResponse;
initialSearch?: string;
}
export function OAuthAppList({ initialData, initialSearch }: OAuthAppListProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState(initialSearch || "");
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [selectedApp, setSelectedApp] = useState<OAuthApplication | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRegenerateDialogOpen, setIsRegenerateDialogOpen] = useState(false);
const [newSecret, setNewSecret] = useState<string | null>(null);
const [isSecretVisible, setIsSecretVisible] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleSearch = () => {
const params = new URLSearchParams(searchParams.toString());
if (searchQuery) {
params.set("search", searchQuery);
} else {
params.delete("search");
}
params.set("page", "1");
router.push(`/admin/oauth?${params.toString()}`);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
const handleDelete = async () => {
if (!selectedApp) return;
setIsLoading(true);
try {
await deleteOAuthApp(selectedApp.id);
setIsDeleteDialogOpen(false);
setSelectedApp(null);
} catch (error) {
console.error("Failed to delete OAuth app:", error);
} finally {
setIsLoading(false);
}
};
const handleRegenerateSecret = async () => {
if (!selectedApp) return;
setIsLoading(true);
try {
const result = await regenerateOAuthSecret(selectedApp.id);
setNewSecret(result.client_secret);
setIsSecretVisible(true);
} catch (error) {
console.error("Failed to regenerate secret:", error);
} finally {
setIsLoading(false);
}
};
const handleToggleActive = async (app: OAuthApplication) => {
setIsLoading(true);
try {
await updateOAuthApp(app.id, { is_active: !app.is_active });
} catch (error) {
console.error("Failed to toggle app status:", error);
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const formatDate = (dateStr: string) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(dateStr));
};
return (
<div className="space-y-4">
{/* Search and Create */}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="Search by name, client ID, or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-10"
/>
</div>
<Button onClick={handleSearch} variant="secondary">
Search
</Button>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create OAuth App
</Button>
</div>
{/* Table */}
<div className="rounded-md border bg-white">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium">Name</TableHead>
<TableHead className="font-medium">Client ID</TableHead>
<TableHead className="font-medium">Scopes</TableHead>
<TableHead className="font-medium">Status</TableHead>
<TableHead className="font-medium">Created</TableHead>
<TableHead className="text-right font-medium">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{initialData.applications.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-gray-500">
No OAuth applications found
</TableCell>
</TableRow>
) : (
initialData.applications.map((app) => (
<TableRow key={app.id} className="hover:bg-gray-50">
<TableCell>
<div>
<div className="font-medium">{app.name}</div>
{app.description && (
<div className="text-sm text-gray-500 truncate max-w-xs">
{app.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{app.client_id.slice(0, 20)}...
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(app.client_id)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1 max-w-xs">
{app.scopes.slice(0, 2).map((scope) => (
<Badge key={scope} variant="secondary" className="text-xs">
{scope}
</Badge>
))}
{app.scopes.length > 2 && (
<Badge variant="secondary" className="text-xs">
+{app.scopes.length - 2}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={app.is_active ? "default" : "secondary"}
className={app.is_active ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}
>
{app.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-gray-600">
{formatDate(app.created_at)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleToggleActive(app)}>
{app.is_active ? (
<>
<EyeOff className="mr-2 h-4 w-4" />
Disable
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
Enable
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedApp(app);
setIsRegenerateDialogOpen(true);
}}
>
<RefreshCw className="mr-2 h-4 w-4" />
Regenerate Secret
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setSelectedApp(app);
setIsDeleteDialogOpen(true);
}}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<PaginationControls
currentPage={initialData.page}
totalPages={initialData.total_pages}
/>
{/* Create Dialog */}
<CreateOAuthAppDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete OAuth Application</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{selectedApp?.name}</strong>?
This will also delete all associated authorization codes, access tokens,
and refresh tokens. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Regenerate Secret Dialog */}
<Dialog
open={isRegenerateDialogOpen}
onOpenChange={(open) => {
setIsRegenerateDialogOpen(open);
if (!open) {
setNewSecret(null);
setIsSecretVisible(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{newSecret ? "New Client Secret Generated" : "Regenerate Client Secret"}
</DialogTitle>
<DialogDescription>
{newSecret
? "Your new client secret is shown below. Make sure to copy it now - you won't be able to see it again!"
: `Are you sure you want to regenerate the client secret for ${selectedApp?.name}? The old secret will be invalidated immediately.`}
</DialogDescription>
</DialogHeader>
{newSecret && (
<div className="space-y-4">
<div className="flex items-center gap-2 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<div className="flex-1">
<label className="text-sm font-medium text-gray-700">Client Secret</label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-sm bg-gray-100 px-3 py-2 rounded break-all">
{isSecretVisible ? newSecret : "••••••••••••••••••••••••••••••••"}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSecretVisible(!isSecretVisible)}
>
{isSecretVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(newSecret)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<p className="text-sm text-yellow-700 bg-yellow-50 p-3 rounded">
This secret will only be shown once. Store it securely!
</p>
</div>
)}
<DialogFooter>
{newSecret ? (
<Button onClick={() => setIsRegenerateDialogOpen(false)}>Done</Button>
) : (
<>
<Button variant="secondary" onClick={() => setIsRegenerateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleRegenerateSecret} disabled={isLoading}>
{isLoading ? "Regenerating..." : "Regenerate Secret"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
import { OAuthAppList } from "./components/OAuthAppList";
import { getOAuthApps } from "./actions";
type OAuthPageSearchParams = {
page?: string;
search?: string;
};
async function OAuthDashboard({
searchParams,
}: {
searchParams: OAuthPageSearchParams;
}) {
const page = searchParams.page ? Number.parseInt(searchParams.page) : 1;
const search = searchParams.search;
const data = await getOAuthApps(page, 20, search);
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">OAuth Applications</h1>
<p className="text-gray-500">
Manage OAuth applications for third-party integrations
</p>
</div>
</div>
<Suspense
key={`${page}-${search}`}
fallback={
<div className="py-10 text-center">Loading OAuth applications...</div>
}
>
<OAuthAppList initialData={data} initialSearch={search} />
</Suspense>
</div>
</div>
);
}
export default async function OAuthDashboardPage({
searchParams,
}: {
searchParams: Promise<OAuthPageSearchParams>;
}) {
"use server";
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedOAuthDashboard = await withAdminAccess(OAuthDashboard);
return <ProtectedOAuthDashboard searchParams={await searchParams} />;
}

View File

@@ -16,6 +16,7 @@ import type {
APIKeyPermission,
Block,
CreateAPIKeyResponse,
CreateOAuthAppRequest,
CreatorDetails,
CreatorsResponse,
Credentials,
@@ -47,10 +48,14 @@ import type {
NodeExecutionResult,
NotificationPreference,
NotificationPreferenceDTO,
OAuthApplication,
OAuthApplicationCreationResult,
OAuthAppsListResponse,
OttoQuery,
OttoResponse,
ProfileDetails,
RefundRequest,
RegenerateSecretResponse,
ReviewSubmissionRequest,
Schedule,
ScheduleCreatable,
@@ -64,6 +69,7 @@ import type {
StoreSubmissionsResponse,
SubmissionStatus,
TransactionHistory,
UpdateOAuthAppRequest,
User,
UserPasswordCredentials,
UsersBalanceHistoryResponse,
@@ -616,6 +622,43 @@ export default class BackendAPI {
return this._get(url);
}
/////////////////////////////////////////
//////// OAuth Admin API ////////////////
/////////////////////////////////////////
getOAuthApps(params?: {
search?: string;
page?: number;
page_size?: number;
}): Promise<OAuthAppsListResponse> {
return this._get("/oauth/admin/apps", params);
}
getOAuthApp(appId: string): Promise<OAuthApplication> {
return this._get(`/oauth/admin/apps/${appId}`);
}
createOAuthApp(
request: CreateOAuthAppRequest,
): Promise<OAuthApplicationCreationResult> {
return this._request("POST", "/oauth/admin/apps", request);
}
updateOAuthApp(
appId: string,
request: UpdateOAuthAppRequest,
): Promise<OAuthApplication> {
return this._request("PATCH", `/oauth/admin/apps/${appId}`, request);
}
deleteOAuthApp(appId: string): Promise<void> {
return this._request("DELETE", `/oauth/admin/apps/${appId}`);
}
regenerateOAuthSecret(appId: string): Promise<RegenerateSecretResponse> {
return this._request("POST", `/oauth/admin/apps/${appId}/regenerate-secret`);
}
////////////////////////////////////////
//////////// V2 LIBRARY API ////////////
////////////////////////////////////////

View File

@@ -1102,6 +1102,57 @@ export type AddUserCreditsResponse = {
new_balance: number;
transaction_key: string;
};
// OAuth Application Types
export type OAuthApplication = {
id: string;
name: string;
description: string | null;
logo_url: string | null;
client_id: string;
redirect_uris: string[];
grant_types: string[];
scopes: string[];
owner_id: string;
is_active: boolean;
created_at: string;
updated_at: string;
};
export type OAuthApplicationCreationResult = {
application: OAuthApplication;
client_secret_plaintext: string;
};
export type OAuthAppsListResponse = {
applications: OAuthApplication[];
total: number;
page: number;
page_size: number;
total_pages: number;
};
export type CreateOAuthAppRequest = {
name: string;
description?: string;
redirect_uris: string[];
scopes: string[];
grant_types?: string[];
owner_id: string;
};
export type UpdateOAuthAppRequest = {
name?: string;
description?: string;
redirect_uris?: string[];
scopes?: string[];
is_active?: boolean;
};
export type RegenerateSecretResponse = {
client_secret: string;
};
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
date: DataType.DATE,
time: DataType.TIME,