mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-19 20:18:22 -05:00
Compare commits
2 Commits
fix/undefi
...
claude/oau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76904bf002 | ||
|
|
fccda4e4d9 |
@@ -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)
|
||||
@@ -31,20 +31,25 @@ from pydantic import BaseModel, Field
|
||||
from backend.data.auth.oauth import (
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
OAuthApplicationCreationResult,
|
||||
OAuthApplicationInfo,
|
||||
TokenIntrospectionResult,
|
||||
consume_authorization_code,
|
||||
create_access_token,
|
||||
create_authorization_code,
|
||||
create_oauth_application,
|
||||
create_refresh_token,
|
||||
delete_oauth_application,
|
||||
get_oauth_application,
|
||||
get_oauth_application_by_id,
|
||||
introspect_token,
|
||||
list_user_oauth_applications,
|
||||
refresh_tokens,
|
||||
regenerate_client_secret,
|
||||
revoke_access_token,
|
||||
revoke_refresh_token,
|
||||
update_oauth_application,
|
||||
user_update_oauth_application,
|
||||
validate_client_credentials,
|
||||
validate_redirect_uri,
|
||||
validate_scopes,
|
||||
@@ -831,3 +836,243 @@ async def _delete_app_current_logo_file(app: OAuthApplicationInfo):
|
||||
logger.warning(
|
||||
f"Failed to delete old logo for OAuth app #{app.id}: {e}", exc_info=e
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# User Self-Service Endpoints (create, update, delete own apps)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class RegenerateSecretResponse(BaseModel):
|
||||
"""Response when regenerating a client secret"""
|
||||
|
||||
client_secret: str = Field(
|
||||
description="New plaintext client secret - shown only once"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/apps",
|
||||
response_model=OAuthApplicationCreationResult,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create OAuth Application",
|
||||
)
|
||||
async def create_oauth_app(
|
||||
request: CreateOAuthAppRequest = Body(),
|
||||
user_id: str = Security(get_user_id),
|
||||
) -> OAuthApplicationCreationResult:
|
||||
"""
|
||||
Create a new OAuth application.
|
||||
|
||||
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.
|
||||
"""
|
||||
# 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=user_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User #{user_id} 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(),
|
||||
user_id: str = Security(get_user_id),
|
||||
) -> OAuthApplicationInfo:
|
||||
"""
|
||||
Update an OAuth application.
|
||||
|
||||
Only the application owner can update their own applications.
|
||||
Can update name, description, redirect URIs, and scopes.
|
||||
"""
|
||||
# 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",
|
||||
)
|
||||
|
||||
# Validate redirect URIs if provided
|
||||
if request.redirect_uris is not None and not request.redirect_uris:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one redirect URI is required",
|
||||
)
|
||||
|
||||
updated_app = await user_update_oauth_application(
|
||||
app_id=app_id,
|
||||
owner_id=user_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
redirect_uris=request.redirect_uris,
|
||||
scopes=validated_scopes,
|
||||
)
|
||||
|
||||
if not updated_app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Application not found or you don't have permission to update it",
|
||||
)
|
||||
|
||||
logger.info(f"User #{user_id} updated OAuth app '{updated_app.name}' (#{app_id})")
|
||||
|
||||
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,
|
||||
user_id: str = Security(get_user_id),
|
||||
):
|
||||
"""
|
||||
Delete an OAuth application.
|
||||
|
||||
Only the application owner can delete their own applications.
|
||||
This will also delete all associated authorization codes, access tokens,
|
||||
and refresh tokens.
|
||||
|
||||
This action is irreversible.
|
||||
"""
|
||||
# Verify ownership
|
||||
app = await get_oauth_application_by_id(app_id)
|
||||
if not app or app.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Application not found or you don't have permission to delete it",
|
||||
)
|
||||
|
||||
# Delete the logo file if it exists
|
||||
await _delete_app_current_logo_file(app)
|
||||
|
||||
deleted = await delete_oauth_application(app_id)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Application not found",
|
||||
)
|
||||
|
||||
logger.info(f"User #{user_id} deleted OAuth app '{app.name}' (#{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,
|
||||
user_id: str = Security(get_user_id),
|
||||
) -> RegenerateSecretResponse:
|
||||
"""
|
||||
Regenerate the client secret for an OAuth application.
|
||||
|
||||
Only the application owner can regenerate the secret.
|
||||
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.
|
||||
"""
|
||||
# Verify ownership
|
||||
app = await get_oauth_application_by_id(app_id)
|
||||
if not app or app.owner_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Application not found or you don't have permission to update it",
|
||||
)
|
||||
|
||||
new_secret = await regenerate_client_secret(app_id)
|
||||
|
||||
if not new_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Application not found",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"User #{user_id} regenerated client secret for OAuth app "
|
||||
f"'{app.name}' (#{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,253 @@ 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 user_update_oauth_application(
|
||||
app_id: str,
|
||||
owner_id: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
redirect_uris: Optional[list[str]] = None,
|
||||
scopes: Optional[list[APIPermission]] = None,
|
||||
) -> Optional[OAuthApplicationInfo]:
|
||||
"""
|
||||
Update an OAuth application (user function - can only update own apps).
|
||||
|
||||
Returns the updated app info, or None if app not found or not owned by user.
|
||||
"""
|
||||
from prisma.types import OAuthApplicationUpdateInput
|
||||
|
||||
app = await PrismaOAuthApplication.prisma().find_unique(where={"id": app_id})
|
||||
if not app or app.ownerId != owner_id:
|
||||
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 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} />;
|
||||
}
|
||||
@@ -1,11 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { UploadIcon, ImageIcon, PowerIcon } from "@phosphor-icons/react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
UploadIcon,
|
||||
ImageIcon,
|
||||
PowerIcon,
|
||||
TrashIcon,
|
||||
KeyIcon,
|
||||
PlusIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Badge } from "@/components/atoms/Badge/Badge";
|
||||
import { useOAuthApps } from "./useOAuthApps";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
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 { Input } from "@/components/atoms/Input/Input";
|
||||
import { Textarea } from "@/components/__legacy__/ui/textarea";
|
||||
import { Checkbox } from "@/components/__legacy__/ui/checkbox";
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
export function OAuthAppsSection() {
|
||||
const {
|
||||
@@ -13,12 +81,57 @@ export function OAuthAppsSection() {
|
||||
isLoading,
|
||||
updatingAppId,
|
||||
uploadingAppId,
|
||||
deletingAppId,
|
||||
regeneratingAppId,
|
||||
isCreating,
|
||||
handleToggleStatus,
|
||||
handleUploadLogo,
|
||||
handleCreateApp,
|
||||
handleDeleteApp,
|
||||
handleRegenerateSecret,
|
||||
} = useOAuthApps();
|
||||
|
||||
const fileInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
|
||||
|
||||
// Create dialog state
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [createStep, setCreateStep] = useState<"form" | "success">("form");
|
||||
const [creationResult, setCreationResult] =
|
||||
useState<OAuthApplicationCreationResult | null>(null);
|
||||
const [isSecretVisible, setIsSecretVisible] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [redirectUris, setRedirectUris] = useState<string[]>([""]);
|
||||
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteAppId, setDeleteAppId] = useState<string | null>(null);
|
||||
const [deleteAppName, setDeleteAppName] = useState<string>("");
|
||||
|
||||
// Regenerate secret dialog state
|
||||
const [regenerateAppId, setRegenerateAppId] = useState<string | null>(null);
|
||||
const [regenerateAppName, setRegenerateAppName] = useState<string>("");
|
||||
const [newSecret, setNewSecret] = useState<string | null>(null);
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCreateStep("form");
|
||||
setCreationResult(null);
|
||||
setIsSecretVisible(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setRedirectUris([""]);
|
||||
setSelectedScopes([]);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleCloseCreateDialog = () => {
|
||||
resetCreateForm();
|
||||
setIsCreateDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (
|
||||
appId: string,
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
@@ -27,10 +140,89 @@ export function OAuthAppsSection() {
|
||||
if (file) {
|
||||
handleUploadLogo(appId, file);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
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 handleSubmitCreate = async () => {
|
||||
setFormError(null);
|
||||
|
||||
const validUris = redirectUris.filter((uri) => uri.trim());
|
||||
if (!name.trim()) {
|
||||
setFormError("Name is required");
|
||||
return;
|
||||
}
|
||||
if (validUris.length === 0) {
|
||||
setFormError("At least one redirect URI is required");
|
||||
return;
|
||||
}
|
||||
if (selectedScopes.length === 0) {
|
||||
setFormError("At least one scope is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await handleCreateApp({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
redirect_uris: validUris,
|
||||
scopes: selectedScopes,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
setCreationResult(result);
|
||||
setCreateStep("success");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (deleteAppId) {
|
||||
await handleDeleteApp(deleteAppId);
|
||||
setDeleteAppId(null);
|
||||
setDeleteAppName("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmRegenerate = async () => {
|
||||
if (regenerateAppId) {
|
||||
const secret = await handleRegenerateSecret(regenerateAppId);
|
||||
if (secret) {
|
||||
setNewSecret(secret);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseRegenerateDialog = () => {
|
||||
setRegenerateAppId(null);
|
||||
setRegenerateAppName("");
|
||||
setNewSecret(null);
|
||||
setIsSecretVisible(false);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-4">
|
||||
@@ -39,109 +231,494 @@ export function OAuthAppsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
if (oauthApps.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>You don't have any OAuth applications.</p>
|
||||
<p className="mt-2 text-sm">
|
||||
OAuth applications can currently <strong>not</strong> be registered
|
||||
via the API. Contact the system administrator to request an OAuth app
|
||||
registration.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-1 lg:grid-cols-2">
|
||||
{oauthApps.map((app) => (
|
||||
<div
|
||||
key={app.id}
|
||||
data-testid="oauth-app-card"
|
||||
className="flex flex-col gap-4 rounded-xl border bg-card p-5"
|
||||
<div className="space-y-4">
|
||||
{/* Create Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
leftIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
{/* Header: Logo, Name, Status */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted">
|
||||
{app.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={app.logo_url}
|
||||
alt={`${app.name} logo`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="h-7 w-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-lg font-semibold">{app.name}</h3>
|
||||
<Badge
|
||||
className="ml-2"
|
||||
variant={app.is_active ? "success" : "error"}
|
||||
>
|
||||
{app.is_active ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
{app.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{app.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
Create OAuth App
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Client ID
|
||||
</span>
|
||||
<code
|
||||
data-testid="oauth-app-client-id"
|
||||
className="mt-1 block w-full truncate rounded-md border bg-muted px-3 py-2 text-xs"
|
||||
>
|
||||
{app.client_id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Footer: Created date and Actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {new Date(app.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={app.is_active ? "outline" : "primary"}
|
||||
size="small"
|
||||
onClick={() => handleToggleStatus(app.id, app.is_active)}
|
||||
loading={updatingAppId === app.id}
|
||||
leftIcon={<PowerIcon className="h-4 w-4" />}
|
||||
>
|
||||
{app.is_active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={(el) => {
|
||||
fileInputRefs.current[app.id] = el;
|
||||
}}
|
||||
onChange={(e) => handleFileChange(app.id, e)}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => fileInputRefs.current[app.id]?.click()}
|
||||
loading={uploadingAppId === app.id}
|
||||
leftIcon={<UploadIcon className="h-4 w-4" />}
|
||||
>
|
||||
{app.logo_url ? "Change " : "Upload "}Logo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Empty State */}
|
||||
{oauthApps.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>You don't have any OAuth applications yet.</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Click "Create OAuth App" to register your first
|
||||
application.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
/* App Cards Grid */
|
||||
<div className="grid gap-4 sm:grid-cols-1 lg:grid-cols-2">
|
||||
{oauthApps.map((app) => (
|
||||
<div
|
||||
key={app.id}
|
||||
data-testid="oauth-app-card"
|
||||
className="flex flex-col gap-4 rounded-xl border bg-card p-5"
|
||||
>
|
||||
{/* Header: Logo, Name, Status */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted">
|
||||
{app.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={app.logo_url}
|
||||
alt={`${app.name} logo`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="h-7 w-7 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-lg font-semibold">
|
||||
{app.name}
|
||||
</h3>
|
||||
<Badge
|
||||
className="ml-2"
|
||||
variant={app.is_active ? "success" : "error"}
|
||||
>
|
||||
{app.is_active ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
{app.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{app.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Client ID
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code
|
||||
data-testid="oauth-app-client-id"
|
||||
className="block flex-1 truncate rounded-md border bg-muted px-3 py-2 text-xs"
|
||||
>
|
||||
{app.client_id}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(app.client_id)}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: Created date and Actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {new Date(app.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={app.is_active ? "outline" : "primary"}
|
||||
size="small"
|
||||
onClick={() => handleToggleStatus(app.id, app.is_active)}
|
||||
loading={updatingAppId === app.id}
|
||||
leftIcon={<PowerIcon className="h-4 w-4" />}
|
||||
>
|
||||
{app.is_active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={(el) => {
|
||||
fileInputRefs.current[app.id] = el;
|
||||
}}
|
||||
onChange={(e) => handleFileChange(app.id, e)}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => fileInputRefs.current[app.id]?.click()}
|
||||
loading={uploadingAppId === app.id}
|
||||
leftIcon={<UploadIcon className="h-4 w-4" />}
|
||||
>
|
||||
Logo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setRegenerateAppId(app.id);
|
||||
setRegenerateAppName(app.name);
|
||||
}}
|
||||
loading={regeneratingAppId === app.id}
|
||||
leftIcon={<KeyIcon className="h-4 w-4" />}
|
||||
>
|
||||
Secret
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setDeleteAppId(app.id);
|
||||
setDeleteAppName(app.name);
|
||||
}}
|
||||
loading={deletingAppId === app.id}
|
||||
leftIcon={<TrashIcon className="h-4 w-4" />}
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create OAuth App Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={handleCloseCreateDialog}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
{createStep === "form" ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create OAuth Application</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new OAuth application for third-party integrations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{formError && (
|
||||
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Application Name *
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Application"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Textarea
|
||||
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 className="text-sm font-medium">
|
||||
Redirect URIs *
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={addRedirectUri}
|
||||
>
|
||||
<PlusIcon 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="small"
|
||||
onClick={() => removeRedirectUri(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Scopes *</label>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-md border bg-muted/30 p-4">
|
||||
{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="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{scope.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{scope.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={handleCloseCreateDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmitCreate}
|
||||
loading={isCreating}
|
||||
>
|
||||
Create Application
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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="rounded-md border border-green-200 bg-green-50 p-4">
|
||||
<h4 className="mb-2 font-medium text-green-800">
|
||||
{creationResult?.application.name}
|
||||
</h4>
|
||||
<p className="text-sm text-green-700">
|
||||
Application created successfully
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">
|
||||
Client ID
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="block flex-1 break-all rounded bg-muted px-3 py-2 text-sm">
|
||||
{creationResult?.application.client_id}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
creationResult?.application.client_id || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-yellow-200 bg-yellow-50 p-4">
|
||||
<label className="text-sm font-medium text-yellow-800">
|
||||
Client Secret (Save this now!)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="block flex-1 break-all rounded border bg-white px-3 py-2 text-sm">
|
||||
{isSecretVisible
|
||||
? creationResult?.client_secret_plaintext
|
||||
: "••••••••••••••••••••••••••••••••"}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setIsSecretVisible(!isSecretVisible)}
|
||||
>
|
||||
{isSecretVisible ? (
|
||||
<EyeSlashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
creationResult?.client_secret_plaintext || "",
|
||||
)
|
||||
}
|
||||
>
|
||||
<CopyIcon 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 variant="primary" onClick={handleCloseCreateDialog}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={deleteAppId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteAppId(null);
|
||||
setDeleteAppName("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete OAuth Application</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{deleteAppName}</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={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Regenerate Secret Dialog */}
|
||||
<Dialog
|
||||
open={regenerateAppId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCloseRegenerateDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{newSecret ? "New Client Secret Generated" : "Regenerate 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 "${regenerateAppName}"? The old secret will be invalidated immediately.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{newSecret && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 rounded-md border border-yellow-200 bg-yellow-50 p-4">
|
||||
<label className="text-sm font-medium text-yellow-800">
|
||||
Client Secret
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="block flex-1 break-all rounded border bg-white px-3 py-2 text-sm">
|
||||
{isSecretVisible
|
||||
? newSecret
|
||||
: "••••••••••••••••••••••••••••••••"}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setIsSecretVisible(!isSecretVisible)}
|
||||
>
|
||||
{isSecretVisible ? (
|
||||
<EyeSlashIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(newSecret)}
|
||||
>
|
||||
<CopyIcon 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>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{newSecret ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCloseRegenerateDialog}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCloseRegenerateDialog}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirmRegenerate}
|
||||
loading={regeneratingAppId !== null && !newSecret}
|
||||
>
|
||||
Regenerate Secret
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
useGetOauthListMyOauthApps,
|
||||
usePatchOauthUpdateAppStatus,
|
||||
@@ -10,12 +10,19 @@ import {
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import BackendAPI from "@/lib/autogpt-server-api";
|
||||
import type { OAuthApplicationCreationResult } from "@/lib/autogpt-server-api/types";
|
||||
|
||||
export const useOAuthApps = () => {
|
||||
const queryClient = getQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [updatingAppId, setUpdatingAppId] = useState<string | null>(null);
|
||||
const [uploadingAppId, setUploadingAppId] = useState<string | null>(null);
|
||||
const [deletingAppId, setDeletingAppId] = useState<string | null>(null);
|
||||
const [regeneratingAppId, setRegeneratingAppId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const { data: oauthAppsResponse, isLoading } = useGetOauthListMyOauthApps({
|
||||
query: { select: okData },
|
||||
@@ -41,6 +48,12 @@ export const useOAuthApps = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateApps = useCallback(() => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: getGetOauthListMyOauthAppsQueryKey(),
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const handleToggleStatus = async (appId: string, currentStatus: boolean) => {
|
||||
try {
|
||||
setUpdatingAppId(appId);
|
||||
@@ -98,12 +111,108 @@ export const useOAuthApps = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateApp = async (request: {
|
||||
name: string;
|
||||
description?: string;
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
}): Promise<OAuthApplicationCreationResult | null> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
const api = new BackendAPI();
|
||||
const result = await api.createMyOAuthApp(request);
|
||||
await invalidateApps();
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "OAuth application created successfully",
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to create OAuth app:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create OAuth application";
|
||||
toast({
|
||||
title: "Error",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApp = async (appId: string): Promise<boolean> => {
|
||||
try {
|
||||
setDeletingAppId(appId);
|
||||
const api = new BackendAPI();
|
||||
await api.deleteMyOAuthApp(appId);
|
||||
await invalidateApps();
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "OAuth application deleted successfully",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete OAuth app:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to delete OAuth application";
|
||||
toast({
|
||||
title: "Error",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setDeletingAppId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateSecret = async (
|
||||
appId: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
setRegeneratingAppId(appId);
|
||||
const api = new BackendAPI();
|
||||
const result = await api.regenerateMyOAuthSecret(appId);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Client secret regenerated successfully",
|
||||
});
|
||||
return result.client_secret;
|
||||
} catch (error) {
|
||||
console.error("Failed to regenerate secret:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to regenerate client secret";
|
||||
toast({
|
||||
title: "Error",
|
||||
description: errorMessage,
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setRegeneratingAppId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
oauthApps: oauthAppsResponse ?? [],
|
||||
isLoading,
|
||||
updatingAppId,
|
||||
uploadingAppId,
|
||||
deletingAppId,
|
||||
regeneratingAppId,
|
||||
isCreating,
|
||||
handleToggleStatus,
|
||||
handleUploadLogo,
|
||||
handleCreateApp,
|
||||
handleDeleteApp,
|
||||
handleRegenerateSecret,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,80 @@ 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`);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
//////// User OAuth API /////////////////
|
||||
/////////////////////////////////////////
|
||||
|
||||
getMyOAuthApps(): Promise<OAuthApplication[]> {
|
||||
return this._get("/oauth/apps/mine");
|
||||
}
|
||||
|
||||
createMyOAuthApp(request: {
|
||||
name: string;
|
||||
description?: string;
|
||||
redirect_uris: string[];
|
||||
scopes: string[];
|
||||
}): Promise<OAuthApplicationCreationResult> {
|
||||
return this._request("POST", "/oauth/apps", request);
|
||||
}
|
||||
|
||||
updateMyOAuthApp(
|
||||
appId: string,
|
||||
request: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
redirect_uris?: string[];
|
||||
scopes?: string[];
|
||||
},
|
||||
): Promise<OAuthApplication> {
|
||||
return this._request("PATCH", `/oauth/apps/${appId}`, request);
|
||||
}
|
||||
|
||||
deleteMyOAuthApp(appId: string): Promise<void> {
|
||||
return this._request("DELETE", `/oauth/apps/${appId}`);
|
||||
}
|
||||
|
||||
regenerateMyOAuthSecret(appId: string): Promise<RegenerateSecretResponse> {
|
||||
return this._request("POST", `/oauth/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