mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
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:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 ////////////
|
||||
////////////////////////////////////////
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user