Compare commits

..

19 Commits

Author SHA1 Message Date
Nicholas Tindle
00a20f77be feat(backend): add waitlist_launch email notification template
The WAITLIST_LAUNCH notification type was referencing a template that
didn't exist, causing FileNotFoundError when trying to notify users
that an agent they waitlisted has launched.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:38:04 -06:00
Nicholas Tindle
4d49536a40 Discard changes to autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts 2026-01-12 15:28:37 -07:00
Nicholas Tindle
6028a2528c refactor(frontend): consolidate waitlist modals and align with Figma design
- Merge JoinWaitlistModal into WaitlistDetailModal for unified experience
- Add MediaCarousel component supporting videos and images with play overlay
- Update WaitlistCard styling to match Figma (rounded-large, line-clamp-5, zinc-800 button)
- Update success state with party emoji and Close button per Figma design
- Add sticky footer for buttons during modal scroll
- Support email input for non-logged-in users

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:27:09 -06:00
Nicholas Tindle
b31cd05675 fix(backend): correct typo in unaffiliatedEmailUsers field name
- Rename unafilliatedEmailUsers -> unaffiliatedEmailUsers in schema.prisma
- Update migration SQL to use correct column name
- Update all references in db.py and model.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:33:38 -06:00
Nicholas Tindle
128366772f refactor(backend): remove apscheduler tables from prisma schema
- Remove apscheduler_jobs and apscheduler_jobs_batched_notifications models
- Delete migration 20260107000001_add_apscheduler_tables
- Remove index rename statements from waitlist migration

APScheduler tables are managed at runtime by APScheduler itself and
should not be part of the Prisma schema.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:29:17 -06:00
Nicholas Tindle
764cdf17fe refactor(frontend): migrate waitlist admin components to generated API hooks
- Convert WaitlistTable to use generated React Query hooks directly
- Convert CreateWaitlistButton to use generated hooks
- Update WaitlistDetailModal to use generated types and design system Dialog
- Remove deprecated waitlist types from types.ts
- Remove deprecated waitlist methods from BackendAPI client
- Delete actions.ts server actions (no longer needed)
- Replace lucide-react icons with Phosphor icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:26:34 -06:00
Nicholas Tindle
1dd83b4cf8 fix(frontend): add text color to status badge fallback in WaitlistTable
Ensures unknown status values have readable text contrast by adding
text-gray-700 to the fallback className.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:09:44 -06:00
Nicholas Tindle
24a34f7ce5 Merge branch 'dev' into ntindle/waitlist 2026-01-12 14:08:48 -07:00
Nicholas Tindle
20fe2c3877 fix(backend): remove PII-exposing fields from public waitlist model
Remove `owner` (User type) and `storeListing` (StoreListingWithVersions)
fields from StoreWaitlistEntry. These fields were never populated but
exposed PII types (email, stripe_customer_id, etc.) in the OpenAPI schema.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:52:51 -06:00
Nicholas Tindle
738c7e2bef fix(platform): address remaining PR review feedback for waitlist
Backend fixes:
- Fix optional field clearing by using model_fields_set
- Re-fetch waitlist data after join operation
- Only mark waitlist as DONE if all notifications succeed
- Fix race condition in email removal with transaction
- Rename waitlist_id to waitlistId for naming consistency

Frontend fixes:
- Migrate useWaitlistSection to generated API hooks
- Migrate JoinWaitlistModal to design system + generated hooks
- Migrate WaitlistSignupsDialog to design system + generated hooks
- Replace lucide-react icons with Phosphor in WaitlistTable
- Add proper error state in WaitlistSignupsDialog
- Update waitlistId naming across components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:43:10 -06:00
Nicholas Tindle
9edfe0fb97 refactor(frontend): migrate EditWaitlistDialog to design system and generated API
- Replace legacy Dialog components with molecules/Dialog
- Replace legacy Input/Label/Textarea with atoms/Input
- Replace legacy Select with atoms/Select
- Replace @/lib/autogpt-server-api/types with @/app/api/__generated__/models
- Replace updateWaitlist action with usePutV2UpdateWaitlist hook
- Remove dependency on BackendAPI in favor of generated React Query hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:49:35 -07:00
Nicholas Tindle
4aabe71001 fix(platform): address PR review feedback for waitlist feature
Backend fixes:
- Fix creator_username null check in store URL construction
- Add embed=True to link_waitlist_to_listing endpoint body param
- Fix race condition in email list with transaction wrapper
- Replace str(e) with generic error messages in admin ValueError handlers
- Add validation requiring user_id or email in waitlist join
- Configure WAITLIST_LAUNCH in notification system (data type, queue, template, subject)
- Change StoreListing cascade delete to SetNull to preserve waitlist data

Frontend fixes:
- Escape internal quotes in CSV export for proper RFC 4180 compliance
- Remove incorrect 'use server' directive from page.tsx
- Replace lucide-react Check icon with Phosphor Icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:40:35 -07:00
Nicholas Tindle
b3999669f2 refactor(platform): simplify waitlist code and remove type duplication
- Backend: Extract _waitlist_to_store_entry helper to reduce duplication
- Backend: Use dict comprehension in update_waitlist_admin for cleaner code
- Frontend: Import types directly from shared types file instead of re-exporting
- Frontend: Remove redundant isMember check in WaitlistCard handleJoinClick

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:25:27 -07:00
Swifty
8c45a5ee98 Merge branch 'dev' into ntindle/waitlist 2026-01-08 12:38:46 +01:00
Nicholas Tindle
4b654c7e9f fix(frontend): Fix lint and type errors in waitlist admin components
- Remove unused WaitlistSignup import
- Change button size from "sm" to "small"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:48:53 -07:00
Nicholas Tindle
8d82e3b633 fix(backend): Use Prisma connect pattern for waitlist-listing relation
Use StoreListing relation with connect pattern instead of directly
setting storeListingId, which doesn't work with Prisma's typed update.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:01:18 -07:00
Nicholas Tindle
d4ecdb64ed feat(platform): Show "On the waitlist" status for joined users
- Add GET /api/store/waitlist/my-memberships endpoint to fetch user's joined waitlists
- Add get_user_waitlist_memberships() db function
- Update useWaitlistSection hook to fetch memberships when logged in
- Update WaitlistCard to show green "On the waitlist" button for members
- Update WaitlistDetailModal to show member status
- Add onSuccess callback to JoinWaitlistModal for optimistic UI updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:15:03 -07:00
Nicholas Tindle
a73fb8f114 feat(platform): Add waitlist feature with admin management and user notifications
Backend:
- Add waitlist admin API routes for CRUD operations
- Add admin functions for waitlist management (create, update, delete, list)
- Add WaitlistLaunchData notification type for user notifications
- Integrate waitlist notifications into store submission approval flow
- Auto-notify waitlist users when linked agent is approved

Frontend:
- Add admin waitlist management page with table, create/edit dialogs
- Add WaitlistSection component to marketplace homepage
- Add WaitlistCard, WaitlistDetailModal, JoinWaitlistModal components
- Add API client methods and types for waitlist operations

Database:
- Add WAITLIST_LAUNCH notification type enum
- Add baseline migration for APScheduler tables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:38:15 -07:00
Nicholas Tindle
2c60aa64ef wip: adding waitlist 2026-01-06 22:13:35 -07:00
60 changed files with 3512 additions and 1775 deletions

View File

@@ -0,0 +1,250 @@
import logging
import autogpt_libs.auth
import fastapi
import fastapi.responses
import backend.api.features.store.db as store_db
import backend.api.features.store.model as store_model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter(
prefix="/admin/waitlist",
tags=["store", "admin", "waitlist"],
dependencies=[fastapi.Security(autogpt_libs.auth.requires_admin_user)],
)
@router.post(
"",
summary="Create Waitlist",
response_model=store_model.WaitlistAdminResponse,
)
async def create_waitlist(
request: store_model.WaitlistCreateRequest,
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
):
"""
Create a new waitlist (admin only).
Args:
request: Waitlist creation details
user_id: Authenticated admin user creating the waitlist
Returns:
WaitlistAdminResponse with the created waitlist details
"""
try:
waitlist = await store_db.create_waitlist_admin(
admin_user_id=user_id,
data=request,
)
return waitlist
except Exception as e:
logger.exception("Error creating waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while creating the waitlist"},
)
@router.get(
"",
summary="List All Waitlists",
response_model=store_model.WaitlistAdminListResponse,
)
async def list_waitlists():
"""
Get all waitlists with admin details (admin only).
Returns:
WaitlistAdminListResponse with all waitlists
"""
try:
return await store_db.get_waitlists_admin()
except Exception as e:
logger.exception("Error listing waitlists: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching waitlists"},
)
@router.get(
"/{waitlist_id}",
summary="Get Waitlist Details",
response_model=store_model.WaitlistAdminResponse,
)
async def get_waitlist(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Get a single waitlist with admin details (admin only).
Args:
waitlist_id: ID of the waitlist to retrieve
Returns:
WaitlistAdminResponse with waitlist details
"""
try:
return await store_db.get_waitlist_admin(waitlist_id)
except ValueError:
logger.warning("Waitlist not found: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error fetching waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching the waitlist"},
)
@router.put(
"/{waitlist_id}",
summary="Update Waitlist",
response_model=store_model.WaitlistAdminResponse,
)
async def update_waitlist(
request: store_model.WaitlistUpdateRequest,
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Update a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist to update
request: Fields to update
Returns:
WaitlistAdminResponse with updated waitlist details
"""
try:
return await store_db.update_waitlist_admin(waitlist_id, request)
except ValueError:
logger.warning("Waitlist not found for update: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error updating waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while updating the waitlist"},
)
@router.delete(
"/{waitlist_id}",
summary="Delete Waitlist",
)
async def delete_waitlist(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Soft delete a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist to delete
Returns:
Success message
"""
try:
deleted = await store_db.delete_waitlist_admin(waitlist_id)
if deleted:
return {"message": "Waitlist deleted successfully"}
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error deleting waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while deleting the waitlist"},
)
@router.get(
"/{waitlist_id}/signups",
summary="Get Waitlist Signups",
response_model=store_model.WaitlistSignupListResponse,
)
async def get_waitlist_signups(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Get all signups for a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist
Returns:
WaitlistSignupListResponse with all signups
"""
try:
return await store_db.get_waitlist_signups_admin(waitlist_id)
except ValueError:
logger.warning("Waitlist not found for signups: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error fetching waitlist signups: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching waitlist signups"},
)
@router.post(
"/{waitlist_id}/link",
summary="Link Waitlist to Store Listing",
response_model=store_model.WaitlistAdminResponse,
)
async def link_waitlist_to_listing(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
store_listing_id: str = fastapi.Body(
..., embed=True, description="The ID of the store listing"
),
):
"""
Link a waitlist to a store listing (admin only).
When the linked store listing is approved/published, waitlist users
will be automatically notified.
Args:
waitlist_id: ID of the waitlist
store_listing_id: ID of the store listing to link
Returns:
WaitlistAdminResponse with updated waitlist details
"""
try:
return await store_db.link_waitlist_to_listing_admin(
waitlist_id, store_listing_id
)
except ValueError:
logger.warning(
"Link failed - waitlist or listing not found: %s, %s",
waitlist_id,
store_listing_id,
)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist or store listing not found"},
)
except Exception as e:
logger.exception("Error linking waitlist to listing: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while linking the waitlist"},
)

View File

@@ -108,9 +108,6 @@ class CredentialsMetaResponse(BaseModel):
host: str | None = Field(
default=None, description="Host pattern for host-scoped credentials"
)
is_system: bool = Field(
default=False, description="Whether this is a system-managed credential"
)
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
@@ -178,8 +175,6 @@ async def callback(
f"Successfully processed OAuth callback for user {user_id} "
f"and provider {provider.value}"
)
from backend.integrations.credentials_store import is_system_credential
return CredentialsMetaResponse(
id=credentials.id,
provider=credentials.provider,
@@ -190,7 +185,6 @@ async def callback(
host=(
credentials.host if isinstance(credentials, HostScopedCredentials) else None
),
is_system=is_system_credential(credentials.id),
)
@@ -198,8 +192,6 @@ async def callback(
async def list_credentials(
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_all_creds(user_id)
return [
CredentialsMetaResponse(
@@ -210,7 +202,6 @@ async def list_credentials(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]
@@ -223,8 +214,6 @@ async def list_credentials_by_provider(
],
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_creds_by_provider(user_id, provider)
return [
CredentialsMetaResponse(
@@ -235,7 +224,6 @@ async def list_credentials_by_provider(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]

View File

@@ -23,6 +23,7 @@ from backend.data.notifications import (
AgentApprovalData,
AgentRejectionData,
NotificationEventModel,
WaitlistLaunchData,
)
from backend.notifications.notifications import queue_notification_async
from backend.util.exceptions import DatabaseError
@@ -1742,6 +1743,29 @@ async def review_store_submission(
# Don't fail the review process if email sending fails
pass
# Notify waitlist users if this is an approval and has a linked waitlist
if is_approved and submission.StoreListing:
try:
frontend_base_url = (
settings.config.frontend_base_url
or settings.config.platform_base_url
)
store_agent = (
await prisma.models.StoreAgent.prisma().find_first_or_raise(
where={"storeListingVersionId": submission.id}
)
)
creator_username = store_agent.creator_username or "unknown"
store_url = f"{frontend_base_url}/marketplace/agent/{creator_username}/{store_agent.slug}"
await notify_waitlist_users_on_launch(
store_listing_id=submission.StoreListing.id,
agent_name=submission.name,
store_url=store_url,
)
except Exception as e:
logger.error(f"Failed to notify waitlist users on agent approval: {e}")
# Don't fail the approval process
# Convert to Pydantic model for consistency
return store_model.StoreSubmission(
listing_id=(submission.StoreListing.id if submission.StoreListing else ""),
@@ -1989,3 +2013,507 @@ async def get_agent_as_admin(
)
return graph
def _waitlist_to_store_entry(
waitlist: prisma.models.WaitlistEntry,
) -> store_model.StoreWaitlistEntry:
"""Convert a WaitlistEntry to StoreWaitlistEntry for public display."""
return store_model.StoreWaitlistEntry(
waitlistId=waitlist.id,
slug=waitlist.slug,
name=waitlist.name,
subHeading=waitlist.subHeading,
videoUrl=waitlist.videoUrl,
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
imageUrls=waitlist.imageUrls or [],
description=waitlist.description,
categories=waitlist.categories,
)
async def get_waitlist() -> list[store_model.StoreWaitlistEntry]:
"""Get all active waitlists for public display."""
try:
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
)
# Filter out closed/done waitlists and sort by votes (descending)
excluded_statuses = {
prisma.enums.WaitlistExternalStatus.CANCELED,
prisma.enums.WaitlistExternalStatus.DONE,
}
active_waitlists = [w for w in waitlists if w.status not in excluded_statuses]
sorted_list = sorted(active_waitlists, key=lambda x: x.votes, reverse=True)
return [_waitlist_to_store_entry(w) for w in sorted_list]
except Exception as e:
logger.error(f"Error fetching waitlists: {e}")
raise DatabaseError("Failed to fetch waitlists") from e
async def get_user_waitlist_memberships(user_id: str) -> list[str]:
"""Get all waitlist IDs that a user has joined."""
try:
user = await prisma.models.User.prisma().find_unique(
where={"id": user_id},
include={"joinedWaitlists": True},
)
if not user or not user.joinedWaitlists:
return []
return [w.id for w in user.joinedWaitlists]
except Exception as e:
logger.error(f"Error fetching user waitlist memberships: {e}")
raise DatabaseError("Failed to fetch waitlist memberships") from e
async def add_user_to_waitlist(
waitlist_id: str, user_id: str | None, email: str | None
) -> store_model.StoreWaitlistEntry:
"""
Add a user to a waitlist.
For logged-in users: connects via joinedUsers relation
For anonymous users: adds email to unaffiliatedEmailUsers array
"""
logger.debug(f"Adding user {user_id or email} to waitlist {waitlist_id}")
if not user_id and not email:
raise ValueError("Either user_id or email must be provided")
try:
# Find the waitlist
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} is no longer available")
if waitlist.status in [
prisma.enums.WaitlistExternalStatus.CANCELED,
prisma.enums.WaitlistExternalStatus.DONE,
]:
raise ValueError(f"Waitlist {waitlist_id} is closed")
if user_id:
# Check if user already joined
joined_user_ids = [u.id for u in (waitlist.joinedUsers or [])]
if user_id in joined_user_ids:
# Already joined - return waitlist info
logger.debug(f"User {user_id} already joined waitlist {waitlist_id}")
else:
# Connect user to waitlist
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"joinedUsers": {"connect": [{"id": user_id}]}},
)
logger.info(f"User {user_id} joined waitlist {waitlist_id}")
# If user was previously in email list, remove them
# Use transaction to prevent race conditions
if email:
async with transaction() as tx:
current_waitlist = await tx.waitlistentry.find_unique(
where={"id": waitlist_id}
)
if current_waitlist and email in (
current_waitlist.unaffiliatedEmailUsers or []
):
updated_emails: list[str] = [
e
for e in (current_waitlist.unaffiliatedEmailUsers or [])
if e != email
]
await tx.waitlistentry.update(
where={"id": waitlist_id},
data={"unaffiliatedEmailUsers": updated_emails},
)
elif email:
# Add email to unaffiliated list if not already present
# Use transaction to prevent race conditions with concurrent signups
async with transaction() as tx:
# Re-fetch within transaction to get latest state
current_waitlist = await tx.waitlistentry.find_unique(
where={"id": waitlist_id}
)
if current_waitlist:
current_emails: list[str] = list(
current_waitlist.unaffiliatedEmailUsers or []
)
if email not in current_emails:
current_emails.append(email)
await tx.waitlistentry.update(
where={"id": waitlist_id},
data={"unaffiliatedEmailUsers": current_emails},
)
logger.info(f"Email {email} added to waitlist {waitlist_id}")
else:
logger.debug(f"Email {email} already on waitlist {waitlist_id}")
# Re-fetch to return updated data
updated_waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id}
)
return _waitlist_to_store_entry(updated_waitlist or waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error adding user to waitlist: {e}")
raise DatabaseError("Failed to add user to waitlist") from e
# ============== Admin Waitlist Functions ==============
def _waitlist_to_admin_response(
waitlist: prisma.models.WaitlistEntry,
) -> store_model.WaitlistAdminResponse:
"""Convert a WaitlistEntry to WaitlistAdminResponse."""
joined_count = len(waitlist.joinedUsers) if waitlist.joinedUsers else 0
email_count = (
len(waitlist.unaffiliatedEmailUsers) if waitlist.unaffiliatedEmailUsers else 0
)
return store_model.WaitlistAdminResponse(
id=waitlist.id,
createdAt=waitlist.createdAt.isoformat() if waitlist.createdAt else "",
updatedAt=waitlist.updatedAt.isoformat() if waitlist.updatedAt else "",
slug=waitlist.slug,
name=waitlist.name,
subHeading=waitlist.subHeading,
description=waitlist.description,
categories=waitlist.categories,
imageUrls=waitlist.imageUrls or [],
videoUrl=waitlist.videoUrl,
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
status=waitlist.status or prisma.enums.WaitlistExternalStatus.NOT_STARTED,
votes=waitlist.votes,
signupCount=joined_count + email_count,
storeListingId=waitlist.storeListingId,
owningUserId=waitlist.owningUserId,
)
async def create_waitlist_admin(
admin_user_id: str,
data: store_model.WaitlistCreateRequest,
) -> store_model.WaitlistAdminResponse:
"""Create a new waitlist (admin only)."""
logger.info(f"Admin {admin_user_id} creating waitlist: {data.name}")
try:
waitlist = await prisma.models.WaitlistEntry.prisma().create(
data=prisma.types.WaitlistEntryCreateInput(
name=data.name,
slug=data.slug,
subHeading=data.subHeading,
description=data.description,
categories=data.categories,
imageUrls=data.imageUrls,
videoUrl=data.videoUrl,
agentOutputDemoUrl=data.agentOutputDemoUrl,
owningUserId=admin_user_id,
status=prisma.enums.WaitlistExternalStatus.NOT_STARTED,
),
include={"joinedUsers": True},
)
return _waitlist_to_admin_response(waitlist)
except Exception as e:
logger.error(f"Error creating waitlist: {e}")
raise DatabaseError("Failed to create waitlist") from e
async def get_waitlists_admin() -> store_model.WaitlistAdminListResponse:
"""Get all waitlists with admin details."""
try:
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
include={"joinedUsers": True},
order={"createdAt": "desc"},
)
return store_model.WaitlistAdminListResponse(
waitlists=[_waitlist_to_admin_response(w) for w in waitlists],
totalCount=len(waitlists),
)
except Exception as e:
logger.error(f"Error fetching waitlists for admin: {e}")
raise DatabaseError("Failed to fetch waitlists") from e
async def get_waitlist_admin(
waitlist_id: str,
) -> store_model.WaitlistAdminResponse:
"""Get a single waitlist with admin details."""
try:
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error fetching waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to fetch waitlist") from e
async def update_waitlist_admin(
waitlist_id: str,
data: store_model.WaitlistUpdateRequest,
) -> store_model.WaitlistAdminResponse:
"""Update a waitlist (admin only)."""
logger.info(f"Updating waitlist {waitlist_id}")
try:
# Build update data from explicitly provided fields
# Use model_fields_set to allow clearing fields by setting them to None
field_mappings = {
"name": data.name,
"slug": data.slug,
"subHeading": data.subHeading,
"description": data.description,
"categories": data.categories,
"imageUrls": data.imageUrls,
"videoUrl": data.videoUrl,
"agentOutputDemoUrl": data.agentOutputDemoUrl,
"storeListingId": data.storeListingId,
}
update_data: dict[str, typing.Any] = {
k: v for k, v in field_mappings.items() if k in data.model_fields_set
}
# Handle status separately due to enum conversion
if "status" in data.model_fields_set and data.status is not None:
update_data["status"] = prisma.enums.WaitlistExternalStatus(data.status)
if not update_data:
# No updates, just return current data
return await get_waitlist_admin(waitlist_id)
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data=prisma.types.WaitlistEntryUpdateInput(**update_data),
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error updating waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to update waitlist") from e
async def delete_waitlist_admin(waitlist_id: str) -> bool:
"""Soft delete a waitlist (admin only)."""
logger.info(f"Soft deleting waitlist {waitlist_id}")
try:
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"isDeleted": True},
)
return waitlist is not None
except Exception as e:
logger.error(f"Error deleting waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to delete waitlist") from e
async def get_waitlist_signups_admin(
waitlist_id: str,
) -> store_model.WaitlistSignupListResponse:
"""Get all signups for a waitlist (admin only)."""
try:
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
signups: list[store_model.WaitlistSignup] = []
# Add user signups
for user in waitlist.joinedUsers or []:
signups.append(
store_model.WaitlistSignup(
type="user",
userId=user.id,
email=user.email,
username=user.name,
)
)
# Add email signups
for email in waitlist.unaffiliatedEmailUsers or []:
signups.append(
store_model.WaitlistSignup(
type="email",
email=email,
)
)
return store_model.WaitlistSignupListResponse(
waitlistId=waitlist_id,
signups=signups,
totalCount=len(signups),
)
except ValueError:
raise
except Exception as e:
logger.error(f"Error fetching signups for waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to fetch waitlist signups") from e
async def link_waitlist_to_listing_admin(
waitlist_id: str,
store_listing_id: str,
) -> store_model.WaitlistAdminResponse:
"""Link a waitlist to a store listing (admin only)."""
logger.info(f"Linking waitlist {waitlist_id} to listing {store_listing_id}")
try:
# Verify the store listing exists
listing = await prisma.models.StoreListing.prisma().find_unique(
where={"id": store_listing_id}
)
if not listing:
raise ValueError(f"Store listing {store_listing_id} not found")
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"StoreListing": {"connect": {"id": store_listing_id}}},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error linking waitlist to listing: {e}")
raise DatabaseError("Failed to link waitlist to listing") from e
async def notify_waitlist_users_on_launch(
store_listing_id: str,
agent_name: str,
store_url: str,
) -> int:
"""
Notify all users on waitlists linked to a store listing when the agent is launched.
Args:
store_listing_id: The ID of the store listing that was approved
agent_name: The name of the approved agent
store_url: The URL to the agent's store page
Returns:
The number of notifications sent
"""
logger.info(f"Notifying waitlist users for store listing {store_listing_id}")
try:
# Find all waitlists linked to this store listing
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where={
"storeListingId": store_listing_id,
"isDeleted": False,
},
include={"joinedUsers": True},
)
if not waitlists:
logger.info(f"No waitlists found for store listing {store_listing_id}")
return 0
notification_count = 0
launched_at = datetime.now(tz=timezone.utc)
for waitlist in waitlists:
# Track notification results for this waitlist
users_to_notify = waitlist.joinedUsers or []
failed_user_ids: list[str] = []
# Notify registered users
for user in users_to_notify:
try:
notification_data = WaitlistLaunchData(
agent_name=agent_name,
waitlist_name=waitlist.name,
store_url=store_url,
launched_at=launched_at,
)
notification_event = NotificationEventModel[WaitlistLaunchData](
user_id=user.id,
type=prisma.enums.NotificationType.WAITLIST_LAUNCH,
data=notification_data,
)
await queue_notification_async(notification_event)
notification_count += 1
except Exception as e:
logger.error(
f"Failed to send waitlist launch notification to user {user.id}: {e}"
)
failed_user_ids.append(user.id)
# Note: For unaffiliated email users, you would need to send emails directly
# since they don't have user IDs for the notification system.
# This could be done via a separate email service.
# For now, we log these for potential manual follow-up or future implementation.
if waitlist.unaffiliatedEmailUsers:
logger.info(
f"Waitlist {waitlist.id} has {len(waitlist.unaffiliatedEmailUsers)} "
f"unaffiliated email users that need email notifications"
)
# Only mark waitlist as DONE if all registered user notifications succeeded
if not failed_user_ids:
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist.id},
data={"status": prisma.enums.WaitlistExternalStatus.DONE},
)
logger.info(f"Updated waitlist {waitlist.id} status to DONE")
else:
logger.warning(
f"Waitlist {waitlist.id} not marked as DONE due to "
f"{len(failed_user_ids)} failed notifications"
)
logger.info(
f"Sent {notification_count} waitlist launch notifications for store listing {store_listing_id}"
)
return notification_count
except Exception as e:
logger.error(
f"Error notifying waitlist users for store listing {store_listing_id}: {e}"
)
# Don't raise - we don't want to fail the approval process
return 0

View File

@@ -221,3 +221,99 @@ class ReviewSubmissionRequest(pydantic.BaseModel):
is_approved: bool
comments: str # External comments visible to creator
internal_comments: str | None = None # Private admin notes
class StoreWaitlistEntry(pydantic.BaseModel):
"""Public waitlist entry - no PII fields exposed."""
waitlistId: str
slug: str
# Content fields
name: str
subHeading: str
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
imageUrls: list[str]
description: str
categories: list[str]
class StoreWaitlistsAllResponse(pydantic.BaseModel):
listings: list[StoreWaitlistEntry]
# Admin Waitlist Models
class WaitlistCreateRequest(pydantic.BaseModel):
"""Request model for creating a new waitlist."""
name: str
slug: str
subHeading: str
description: str
categories: list[str] = []
imageUrls: list[str] = []
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
class WaitlistUpdateRequest(pydantic.BaseModel):
"""Request model for updating a waitlist."""
name: str | None = None
slug: str | None = None
subHeading: str | None = None
description: str | None = None
categories: list[str] | None = None
imageUrls: list[str] | None = None
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
status: str | None = None # WaitlistExternalStatus enum value
storeListingId: str | None = None # Link to a store listing
class WaitlistAdminResponse(pydantic.BaseModel):
"""Admin response model with full waitlist details including internal data."""
id: str
createdAt: str
updatedAt: str
slug: str
name: str
subHeading: str
description: str
categories: list[str]
imageUrls: list[str]
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
status: prisma.enums.WaitlistExternalStatus
votes: int
signupCount: int # Total count of joinedUsers + unaffiliatedEmailUsers
storeListingId: str | None = None
owningUserId: str
class WaitlistSignup(pydantic.BaseModel):
"""Individual signup entry for a waitlist."""
type: str # "user" or "email"
userId: str | None = None
email: str | None = None
username: str | None = None # For user signups
class WaitlistSignupListResponse(pydantic.BaseModel):
"""Response model for listing waitlist signups."""
waitlistId: str
signups: list[WaitlistSignup]
totalCount: int
class WaitlistAdminListResponse(pydantic.BaseModel):
"""Response model for listing all waitlists (admin view)."""
waitlists: list[WaitlistAdminResponse]
totalCount: int

View File

@@ -7,6 +7,7 @@ from typing import Literal
import autogpt_libs.auth
import fastapi
import fastapi.responses
from autogpt_libs.auth.dependencies import get_optional_user_id
import backend.data.graph
import backend.util.json
@@ -78,6 +79,63 @@ async def update_or_create_profile(
return updated_profile
##############################################
############## Waitlist Endpoints ############
##############################################
@router.get(
"/waitlist",
summary="Get the agent waitlist",
tags=["store", "public"],
response_model=store_model.StoreWaitlistsAllResponse,
)
async def get_waitlist():
"""
Get all active waitlists for public display.
"""
waitlists = await store_db.get_waitlist()
return store_model.StoreWaitlistsAllResponse(listings=waitlists)
@router.get(
"/waitlist/my-memberships",
summary="Get waitlist IDs the current user has joined",
tags=["store", "private"],
)
async def get_my_waitlist_memberships(
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
) -> list[str]:
"""Returns list of waitlist IDs the authenticated user has joined."""
return await store_db.get_user_waitlist_memberships(user_id)
@router.post(
path="/waitlist/{waitlist_id}/join",
summary="Add self to the agent waitlist",
tags=["store", "public"],
response_model=store_model.StoreWaitlistEntry,
)
async def add_self_to_waitlist(
user_id: str | None = fastapi.Security(get_optional_user_id),
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist to join"),
email: str | None = fastapi.Body(
default=None, embed=True, description="Email address for unauthenticated users"
),
):
"""
Add the current user to the agent waitlist.
"""
if not user_id and not email:
raise fastapi.HTTPException(
status_code=400,
detail="Either user authentication or email address is required",
)
waitlist_entry = await store_db.add_user_to_waitlist(
waitlist_id=waitlist_id, user_id=user_id, email=email
)
return waitlist_entry
##############################################
############### Agent Endpoints ##############
##############################################

View File

@@ -19,6 +19,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.store_admin_routes
import backend.api.features.admin.waitlist_admin_routes
import backend.api.features.builder
import backend.api.features.builder.routes
import backend.api.features.chat.routes as chat_routes
@@ -283,6 +284,11 @@ app.include_router(
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router(
backend.api.features.admin.waitlist_admin_routes.router,
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router(
backend.api.features.admin.credit_admin_routes.router,
tags=["v2", "admin"],

View File

@@ -211,6 +211,22 @@ class AgentRejectionData(BaseNotificationData):
return value
class WaitlistLaunchData(BaseNotificationData):
"""Notification data for when an agent from a waitlist is launched."""
agent_name: str
waitlist_name: str
store_url: str
launched_at: datetime
@field_validator("launched_at")
@classmethod
def validate_timezone(cls, value: datetime):
if value.tzinfo is None:
raise ValueError("datetime must have timezone information")
return value
NotificationData = Annotated[
Union[
AgentRunData,
@@ -223,6 +239,7 @@ NotificationData = Annotated[
DailySummaryData,
RefundRequestData,
BaseSummaryData,
WaitlistLaunchData,
],
Field(discriminator="type"),
]
@@ -273,6 +290,7 @@ def get_notif_data_type(
NotificationType.REFUND_PROCESSED: RefundRequestData,
NotificationType.AGENT_APPROVED: AgentApprovalData,
NotificationType.AGENT_REJECTED: AgentRejectionData,
NotificationType.WAITLIST_LAUNCH: WaitlistLaunchData,
}[notification_type]
@@ -318,6 +336,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: QueueType.ADMIN,
NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE,
NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE,
NotificationType.WAITLIST_LAUNCH: QueueType.IMMEDIATE,
}
return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE)
@@ -337,6 +356,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: "refund_processed.html",
NotificationType.AGENT_APPROVED: "agent_approved.html",
NotificationType.AGENT_REJECTED: "agent_rejected.html",
NotificationType.WAITLIST_LAUNCH: "waitlist_launch.html",
}[self.notification_type]
@property
@@ -354,6 +374,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed",
NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!",
NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates",
NotificationType.WAITLIST_LAUNCH: "🚀 {{data.agent_name}} is now available!",
}[self.notification_type]

View File

@@ -245,13 +245,6 @@ DEFAULT_CREDENTIALS = [
webshare_proxy_credentials,
]
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
def is_system_credential(credential_id: str) -> bool:
"""Check if a credential ID belongs to a system-managed credential."""
return credential_id in SYSTEM_CREDENTIAL_IDS
class IntegrationCredentialsStore:
def __init__(self):

View File

@@ -0,0 +1,59 @@
{# Waitlist Launch Notification Email Template #}
{#
Template variables:
data.agent_name: the name of the launched agent
data.waitlist_name: the name of the waitlist the user joined
data.store_url: URL to view the agent in the store
data.launched_at: when the agent was launched
Subject: {{ data.agent_name }} is now available!
#}
{% block content %}
<h1 style="color: #7c3aed; font-size: 32px; font-weight: 700; margin: 0 0 24px 0; text-align: center;">
The wait is over!
</h1>
<p style="color: #586069; font-size: 18px; text-align: center; margin: 0 0 24px 0;">
<strong>'{{ data.agent_name }}'</strong> is now live in the AutoGPT Store!
</p>
<div style="height: 32px; background: transparent;"></div>
<div style="background: #f3e8ff; border: 1px solid #d8b4fe; border-radius: 8px; padding: 20px; margin: 0;">
<h3 style="color: #6b21a8; font-size: 16px; font-weight: 600; margin: 0 0 12px 0;">
You're one of the first to know!
</h3>
<p style="color: #6b21a8; margin: 0; font-size: 16px; line-height: 1.5;">
You signed up for the <strong>{{ data.waitlist_name }}</strong> waitlist, and we're excited to let you know that this agent is now ready for you to use.
</p>
</div>
<div style="height: 32px; background: transparent;"></div>
<div style="text-align: center; margin: 24px 0;">
<a href="{{ data.store_url }}" style="display: inline-block; background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); color: white; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: 600; font-size: 16px;">
Get {{ data.agent_name }} Now
</a>
</div>
<div style="height: 32px; background: transparent;"></div>
<div style="background: #d1ecf1; border: 1px solid #bee5eb; border-radius: 8px; padding: 20px; margin: 0;">
<h3 style="color: #0c5460; font-size: 16px; font-weight: 600; margin: 0 0 12px 0;">
What can you do now?
</h3>
<ul style="color: #0c5460; margin: 0; padding-left: 18px; font-size: 16px; line-height: 1.6;">
<li>Visit the store to learn more about what this agent can do</li>
<li>Install and start using the agent right away</li>
<li>Share it with others who might find it useful</li>
</ul>
</div>
<div style="height: 32px; background: transparent;"></div>
<p style="color: #6a737d; font-size: 14px; text-align: center; margin: 24px 0;">
Thank you for helping us prioritize what to build! Your interest made this happen.
</p>
{% endblock %}

View File

@@ -0,0 +1,53 @@
-- CreateEnum
CREATE TYPE "WaitlistExternalStatus" AS ENUM ('DONE', 'NOT_STARTED', 'CANCELED', 'WORK_IN_PROGRESS');
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'WAITLIST_LAUNCH';
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"storeListingId" TEXT,
"owningUserId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"search" tsvector DEFAULT ''::tsvector,
"name" TEXT NOT NULL,
"subHeading" TEXT NOT NULL,
"videoUrl" TEXT,
"agentOutputDemoUrl" TEXT,
"imageUrls" TEXT[],
"description" TEXT NOT NULL,
"categories" TEXT[],
"status" "WaitlistExternalStatus" NOT NULL DEFAULT 'NOT_STARTED',
"votes" INTEGER NOT NULL DEFAULT 0,
"unaffiliatedEmailUsers" TEXT[] DEFAULT ARRAY[]::TEXT[],
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_joinedWaitlists" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_joinedWaitlists_AB_unique" ON "_joinedWaitlists"("A", "B");
-- CreateIndex
CREATE INDEX "_joinedWaitlists_B_index" ON "_joinedWaitlists"("B");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_owningUserId_fkey" FOREIGN KEY ("owningUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_B_fkey" FOREIGN KEY ("B") REFERENCES "WaitlistEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -67,6 +67,10 @@ model User {
OAuthAuthorizationCodes OAuthAuthorizationCode[]
OAuthAccessTokens OAuthAccessToken[]
OAuthRefreshTokens OAuthRefreshToken[]
// Waitlist relations
waitlistEntries WaitlistEntry[]
joinedWaitlists WaitlistEntry[] @relation("joinedWaitlists")
}
enum OnboardingStep {
@@ -228,6 +232,7 @@ enum NotificationType {
REFUND_PROCESSED
AGENT_APPROVED
AGENT_REJECTED
WAITLIST_LAUNCH
}
model NotificationEvent {
@@ -834,7 +839,8 @@ model StoreListing {
OwningUser User @relation(fields: [owningUserId], references: [id])
// Relations
Versions StoreListingVersion[] @relation("ListingVersions")
Versions StoreListingVersion[] @relation("ListingVersions")
waitlistEntries WaitlistEntry[]
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
@@unique([agentGraphId])
@@ -924,6 +930,47 @@ model StoreListingReview {
@@index([reviewByUserId])
}
enum WaitlistExternalStatus {
DONE
NOT_STARTED
CANCELED
WORK_IN_PROGRESS
}
model WaitlistEntry {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
storeListingId String?
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: SetNull)
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
slug String
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
// Content fields
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
categories String[]
//Waitlist specific fields
status WaitlistExternalStatus @default(NOT_STARTED)
votes Int @default(0) // Hide from frontend api
joinedUsers User[] @relation("joinedWaitlists")
// NOTE: DO NOT DOUBLE SEND TO THESE USERS, IF THEY HAVE SIGNED UP SINCE THEY MAY HAVE ALREADY RECEIVED AN EMAIL
// DOUBLE CHECK WHEN SENDING THAT THEY ARE NOT IN THE JOINED USERS LIST ALSO
unaffiliatedEmailUsers String[] @default([])
isDeleted Boolean @default(false)
}
enum SubmissionStatus {
DRAFT // Being prepared, not yet submitted
PENDING // Submitted, awaiting review

View File

@@ -3,13 +3,6 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
// Externalize OpenTelemetry packages to fix Turbopack HMR issues
serverExternalPackages: [
"@opentelemetry/instrumentation",
"@opentelemetry/sdk-node",
"import-in-the-middle",
"require-in-the-middle",
],
experimental: {
serverActions: {
bodySizeLimit: "256mb",

View File

@@ -117,7 +117,6 @@
},
"devDependencies": {
"@chromatic-com/storybook": "4.1.2",
"@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5",
"@storybook/addon-docs": "9.1.5",
@@ -141,7 +140,6 @@
"eslint": "8.57.1",
"eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5",
"import-in-the-middle": "2.0.2",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",
"orval": "7.13.0",
@@ -149,7 +147,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"require-in-the-middle": "8.0.1",
"require-in-the-middle": "7.5.2",
"storybook": "9.1.5",
"tailwindcss": "3.4.17",
"typescript": "5.9.3"

View File

@@ -270,9 +270,6 @@ importers:
'@chromatic-com/storybook':
specifier: 4.1.2
version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
'@opentelemetry/instrumentation':
specifier: 0.209.0
version: 0.209.0(@opentelemetry/api@1.9.0)
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
@@ -342,9 +339,6 @@ importers:
eslint-plugin-storybook:
specifier: 9.1.5
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
import-in-the-middle:
specifier: 2.0.2
version: 2.0.2
msw:
specifier: 2.11.6
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
@@ -367,8 +361,8 @@ importers:
specifier: 0.7.1
version: 0.7.1(prettier@3.6.2)
require-in-the-middle:
specifier: 8.0.1
version: 8.0.1
specifier: 7.5.2
version: 7.5.2
storybook:
specifier: 9.1.5
version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)
@@ -1553,10 +1547,6 @@ packages:
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.209.0':
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
@@ -1711,12 +1701,6 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.209.0':
resolution: {integrity: sha512-Cwe863ojTCnFlxVuuhG7s6ODkAOzKsAEthKAcI4MDRYz1OmGWYnmSl4X2pbyS+hBxVTdvfZePfoEA01IjqcEyw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/redis-common@0.38.2':
resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -4973,8 +4957,8 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-in-the-middle@2.0.2:
resolution: {integrity: sha512-qet/hkGt3EbNGVtbDfPu0BM+tCqBS8wT1SYrstPaDKoWtshsC6licOemz7DVtpBEyvDNzo8UTBf9/GwWuSDZ9w==}
import-in-the-middle@2.0.1:
resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@@ -6518,6 +6502,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-in-the-middle@7.5.2:
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
engines: {node: '>=8.6.0'}
require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
@@ -8732,10 +8720,6 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs@0.209.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api@1.9.0': {}
'@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)':
@@ -8936,16 +8920,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.209.0
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
@@ -9125,7 +9100,7 @@ snapshots:
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -9969,7 +9944,7 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.38.0
'@sentry/core': 10.27.0
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
transitivePeerDependencies:
- supports-color
@@ -10008,7 +9983,7 @@ snapshots:
'@sentry/core': 10.27.0
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
@@ -12817,7 +12792,7 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
import-in-the-middle@2.0.2:
import-in-the-middle@2.0.1:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
@@ -14656,6 +14631,14 @@ snapshots:
require-from-string@2.0.2: {}
require-in-the-middle@7.5.2:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3

View File

@@ -1,5 +1,5 @@
import { Sidebar } from "@/components/__legacy__/Sidebar";
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
import { Users, DollarSign, UserSearch, FileText, Clock } from "lucide-react";
import { IconSliders } from "@/components/__legacy__/ui/icons";
@@ -11,6 +11,11 @@ const sidebarLinkGroups = [
href: "/admin/marketplace",
icon: <Users className="h-6 w-6" />,
},
{
text: "Waitlist Management",
href: "/admin/waitlist",
icon: <Clock className="h-6 w-6" />,
},
{
text: "User Spending",
href: "/admin/spending",

View File

@@ -0,0 +1,217 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
usePostV2CreateWaitlist,
getGetV2ListAllWaitlistsQueryKey,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { Plus } from "@phosphor-icons/react";
export function CreateWaitlistButton() {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const queryClient = useQueryClient();
const createWaitlistMutation = usePostV2CreateWaitlist({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Success",
description: "Waitlist created successfully",
});
setOpen(false);
setFormData({
name: "",
slug: "",
subHeading: "",
description: "",
categories: "",
imageUrls: "",
videoUrl: "",
agentOutputDemoUrl: "",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to create waitlist",
});
}
},
onError: (error) => {
console.error("Error creating waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to create waitlist",
});
},
},
});
const [formData, setFormData] = useState({
name: "",
slug: "",
subHeading: "",
description: "",
categories: "",
imageUrls: "",
videoUrl: "",
agentOutputDemoUrl: "",
});
function handleInputChange(id: string, value: string) {
setFormData((prev) => ({
...prev,
[id]: value,
}));
}
function generateSlug(name: string) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
createWaitlistMutation.mutate({
data: {
name: formData.name,
slug: formData.slug || generateSlug(formData.name),
subHeading: formData.subHeading,
description: formData.description,
categories: formData.categories
? formData.categories.split(",").map((c) => c.trim())
: [],
imageUrls: formData.imageUrls
? formData.imageUrls.split(",").map((u) => u.trim())
: [],
videoUrl: formData.videoUrl || null,
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
},
});
}
return (
<>
<Button onClick={() => setOpen(true)}>
<Plus size={16} className="mr-2" />
Create Waitlist
</Button>
<Dialog
title="Create New Waitlist"
controlled={{
isOpen: open,
set: async (isOpen) => setOpen(isOpen),
}}
onClose={() => setOpen(false)}
styling={{ maxWidth: "600px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
Create a new waitlist for an upcoming agent. Users can sign up to be
notified when it launches.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
id="name"
label="Name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="SEO Analysis Agent"
required
/>
<Input
id="slug"
label="Slug"
value={formData.slug}
onChange={(e) => handleInputChange("slug", e.target.value)}
placeholder="seo-analysis-agent (auto-generated if empty)"
/>
<Input
id="subHeading"
label="Subheading"
value={formData.subHeading}
onChange={(e) => handleInputChange("subHeading", e.target.value)}
placeholder="Analyze your website's SEO in minutes"
required
/>
<Input
id="description"
label="Description"
type="textarea"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="Detailed description of what this agent does..."
rows={4}
required
/>
<Input
id="categories"
label="Categories (comma-separated)"
value={formData.categories}
onChange={(e) => handleInputChange("categories", e.target.value)}
placeholder="SEO, Marketing, Analysis"
/>
<Input
id="imageUrls"
label="Image URLs (comma-separated)"
value={formData.imageUrls}
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
placeholder="https://example.com/image1.jpg, https://example.com/image2.jpg"
/>
<Input
id="videoUrl"
label="Video URL (optional)"
value={formData.videoUrl}
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
placeholder="https://youtube.com/watch?v=..."
/>
<Input
id="agentOutputDemoUrl"
label="Output Demo URL (optional)"
value={formData.agentOutputDemoUrl}
onChange={(e) =>
handleInputChange("agentOutputDemoUrl", e.target.value)
}
placeholder="https://example.com/demo-output.mp4"
/>
<Dialog.Footer>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={createWaitlistMutation.isPending}>
Create Waitlist
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { usePutV2UpdateWaitlist } from "@/app/api/__generated__/endpoints/admin/admin";
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
import type { WaitlistUpdateRequest } from "@/app/api/__generated__/models/waitlistUpdateRequest";
import { WaitlistExternalStatus } from "@/app/api/__generated__/models/waitlistExternalStatus";
type EditWaitlistDialogProps = {
waitlist: WaitlistAdminResponse;
onClose: () => void;
onSave: () => void;
};
const STATUS_OPTIONS = [
{ value: WaitlistExternalStatus.NOT_STARTED, label: "Not Started" },
{ value: WaitlistExternalStatus.WORK_IN_PROGRESS, label: "Work In Progress" },
{ value: WaitlistExternalStatus.DONE, label: "Done" },
{ value: WaitlistExternalStatus.CANCELED, label: "Canceled" },
];
export function EditWaitlistDialog({
waitlist,
onClose,
onSave,
}: EditWaitlistDialogProps) {
const { toast } = useToast();
const updateWaitlistMutation = usePutV2UpdateWaitlist();
const [formData, setFormData] = useState({
name: waitlist.name,
slug: waitlist.slug,
subHeading: waitlist.subHeading,
description: waitlist.description,
categories: waitlist.categories.join(", "),
imageUrls: waitlist.imageUrls.join(", "),
videoUrl: waitlist.videoUrl || "",
agentOutputDemoUrl: waitlist.agentOutputDemoUrl || "",
status: waitlist.status,
storeListingId: waitlist.storeListingId || "",
});
function handleInputChange(id: string, value: string) {
setFormData((prev) => ({
...prev,
[id]: value,
}));
}
function handleStatusChange(value: string) {
setFormData((prev) => ({
...prev,
status: value as WaitlistExternalStatus,
}));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const updateData: WaitlistUpdateRequest = {
name: formData.name,
slug: formData.slug,
subHeading: formData.subHeading,
description: formData.description,
categories: formData.categories
? formData.categories.split(",").map((c) => c.trim())
: [],
imageUrls: formData.imageUrls
? formData.imageUrls.split(",").map((u) => u.trim())
: [],
videoUrl: formData.videoUrl || null,
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
status: formData.status,
storeListingId: formData.storeListingId || null,
};
updateWaitlistMutation.mutate(
{ waitlistId: waitlist.id, data: updateData },
{
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Success",
description: "Waitlist updated successfully",
});
onSave();
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to update waitlist",
});
}
},
onError: () => {
toast({
variant: "destructive",
title: "Error",
description: "Failed to update waitlist",
});
},
},
);
}
return (
<Dialog
title="Edit Waitlist"
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "600px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
Update the waitlist details. Changes will be reflected immediately.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
id="name"
label="Name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
/>
<Input
id="slug"
label="Slug"
value={formData.slug}
onChange={(e) => handleInputChange("slug", e.target.value)}
/>
<Input
id="subHeading"
label="Subheading"
value={formData.subHeading}
onChange={(e) => handleInputChange("subHeading", e.target.value)}
required
/>
<Input
id="description"
label="Description"
type="textarea"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
rows={4}
required
/>
<Select
id="status"
label="Status"
value={formData.status}
onValueChange={handleStatusChange}
options={STATUS_OPTIONS}
/>
<Input
id="categories"
label="Categories (comma-separated)"
value={formData.categories}
onChange={(e) => handleInputChange("categories", e.target.value)}
/>
<Input
id="imageUrls"
label="Image URLs (comma-separated)"
value={formData.imageUrls}
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
/>
<Input
id="videoUrl"
label="Video URL"
value={formData.videoUrl}
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
/>
<Input
id="agentOutputDemoUrl"
label="Output Demo URL"
value={formData.agentOutputDemoUrl}
onChange={(e) =>
handleInputChange("agentOutputDemoUrl", e.target.value)
}
/>
<Input
id="storeListingId"
label="Store Listing ID (for linking)"
value={formData.storeListingId}
onChange={(e) =>
handleInputChange("storeListingId", e.target.value)
}
placeholder="Leave empty if not linked"
/>
<Dialog.Footer>
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={updateWaitlistMutation.isPending}>
Save Changes
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { User, Envelope, DownloadSimple } from "@phosphor-icons/react";
import { useGetV2GetWaitlistSignups } from "@/app/api/__generated__/endpoints/admin/admin";
type WaitlistSignupsDialogProps = {
waitlistId: string;
onClose: () => void;
};
export function WaitlistSignupsDialog({
waitlistId,
onClose,
}: WaitlistSignupsDialogProps) {
const {
data: signupsResponse,
isLoading,
isError,
} = useGetV2GetWaitlistSignups(waitlistId);
const signups = signupsResponse?.status === 200 ? signupsResponse.data : null;
function exportToCSV() {
if (!signups) return;
const headers = ["Type", "Email", "User ID", "Username"];
const rows = signups.signups.map((signup) => [
signup.type,
signup.email || "",
signup.userId || "",
signup.username || "",
]);
const escapeCell = (cell: string) => `"${cell.replace(/"/g, '""')}"`;
const csvContent = [
headers.join(","),
...rows.map((row) => row.map(escapeCell).join(",")),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `waitlist-${waitlistId}-signups.csv`;
a.click();
window.URL.revokeObjectURL(url);
}
function renderContent() {
if (isLoading) {
return <div className="py-10 text-center">Loading signups...</div>;
}
if (isError) {
return (
<div className="py-10 text-center text-red-500">
Failed to load signups. Please try again.
</div>
);
}
if (!signups || signups.signups.length === 0) {
return (
<div className="py-10 text-center text-gray-500">
No signups yet for this waitlist.
</div>
);
}
return (
<>
<div className="flex justify-end">
<Button variant="secondary" size="small" onClick={exportToCSV}>
<DownloadSimple className="mr-2 h-4 w-4" size={16} />
Export CSV
</Button>
</div>
<div className="max-h-[400px] overflow-y-auto rounded-md border">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">
Type
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
Email / Username
</th>
<th className="px-4 py-3 text-left text-sm font-medium">
User ID
</th>
</tr>
</thead>
<tbody className="divide-y">
{signups.signups.map((signup, index) => (
<tr key={index}>
<td className="px-4 py-3">
{signup.type === "user" ? (
<span className="flex items-center gap-1 text-blue-600">
<User className="h-4 w-4" size={16} /> User
</span>
) : (
<span className="flex items-center gap-1 text-gray-600">
<Envelope className="h-4 w-4" size={16} /> Email
</span>
)}
</td>
<td className="px-4 py-3">
{signup.type === "user"
? signup.username || signup.email
: signup.email}
</td>
<td className="px-4 py-3 font-mono text-sm">
{signup.userId || "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
return (
<Dialog
title="Waitlist Signups"
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "700px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
{signups
? `${signups.totalCount} total signups`
: "Loading signups..."}
</p>
{renderContent()}
<Dialog.Footer>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { Button } from "@/components/atoms/Button/Button";
import {
useGetV2ListAllWaitlists,
useDeleteV2DeleteWaitlist,
getGetV2ListAllWaitlistsQueryKey,
} from "@/app/api/__generated__/endpoints/admin/admin";
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
import { EditWaitlistDialog } from "./EditWaitlistDialog";
import { WaitlistSignupsDialog } from "./WaitlistSignupsDialog";
import { Trash, PencilSimple, Users, Link } from "@phosphor-icons/react";
import { useToast } from "@/components/molecules/Toast/use-toast";
export function WaitlistTable() {
const [editingWaitlist, setEditingWaitlist] =
useState<WaitlistAdminResponse | null>(null);
const [viewingSignups, setViewingSignups] = useState<string | null>(null);
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: response, isLoading, error } = useGetV2ListAllWaitlists();
const deleteWaitlistMutation = useDeleteV2DeleteWaitlist({
mutation: {
onSuccess: () => {
toast({
title: "Success",
description: "Waitlist deleted successfully",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
},
onError: (error) => {
console.error("Error deleting waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete waitlist",
});
},
},
});
function handleDelete(waitlistId: string) {
if (!confirm("Are you sure you want to delete this waitlist?")) return;
deleteWaitlistMutation.mutate({ waitlistId });
}
function handleWaitlistSaved() {
setEditingWaitlist(null);
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
}
function formatStatus(status: string) {
const statusColors: Record<string, string> = {
NOT_STARTED: "bg-gray-100 text-gray-800",
WORK_IN_PROGRESS: "bg-blue-100 text-blue-800",
DONE: "bg-green-100 text-green-800",
CANCELED: "bg-red-100 text-red-800",
};
return (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${statusColors[status] || "bg-gray-100 text-gray-700"}`}
>
{status.replace(/_/g, " ")}
</span>
);
}
function formatDate(dateStr: string) {
if (!dateStr) return "-";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(dateStr));
}
if (isLoading) {
return <div className="py-10 text-center">Loading waitlists...</div>;
}
if (error) {
return (
<div className="py-10 text-center text-red-500">
Error loading waitlists. Please try again.
</div>
);
}
const waitlists = response?.status === 200 ? response.data.waitlists : [];
if (waitlists.length === 0) {
return (
<div className="py-10 text-center text-gray-500">
No waitlists found. Create one to get started!
</div>
);
}
return (
<>
<div className="rounded-md border bg-white">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium">Name</TableHead>
<TableHead className="font-medium">Status</TableHead>
<TableHead className="font-medium">Signups</TableHead>
<TableHead className="font-medium">Votes</TableHead>
<TableHead className="font-medium">Created</TableHead>
<TableHead className="font-medium">Linked Agent</TableHead>
<TableHead className="font-medium">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{waitlists.map((waitlist) => (
<TableRow key={waitlist.id}>
<TableCell>
<div>
<div className="font-medium">{waitlist.name}</div>
<div className="text-sm text-gray-500">
{waitlist.subHeading}
</div>
</div>
</TableCell>
<TableCell>{formatStatus(waitlist.status)}</TableCell>
<TableCell>{waitlist.signupCount}</TableCell>
<TableCell>{waitlist.votes}</TableCell>
<TableCell>{formatDate(waitlist.createdAt)}</TableCell>
<TableCell>
{waitlist.storeListingId ? (
<span className="text-green-600">
<Link size={16} className="inline" /> Linked
</span>
) : (
<span className="text-gray-400">Not linked</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="small"
onClick={() => setViewingSignups(waitlist.id)}
title="View signups"
>
<Users size={16} />
</Button>
<Button
variant="ghost"
size="small"
onClick={() => setEditingWaitlist(waitlist)}
title="Edit"
>
<PencilSimple size={16} />
</Button>
<Button
variant="ghost"
size="small"
onClick={() => handleDelete(waitlist.id)}
title="Delete"
disabled={deleteWaitlistMutation.isPending}
>
<Trash size={16} className="text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{editingWaitlist && (
<EditWaitlistDialog
waitlist={editingWaitlist}
onClose={() => setEditingWaitlist(null)}
onSave={handleWaitlistSaved}
/>
)}
{viewingSignups && (
<WaitlistSignupsDialog
waitlistId={viewingSignups}
onClose={() => setViewingSignups(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,36 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
import { WaitlistTable } from "./components/WaitlistTable";
import { CreateWaitlistButton } from "./components/CreateWaitlistButton";
function WaitlistDashboard() {
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">Waitlist Management</h1>
<p className="text-gray-500">
Manage upcoming agent waitlists and track signups
</p>
</div>
<CreateWaitlistButton />
</div>
<Suspense
fallback={
<div className="py-10 text-center">Loading waitlists...</div>
}
>
<WaitlistTable />
</Suspense>
</div>
</div>
);
}
export default async function WaitlistDashboardPage() {
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedWaitlistDashboard = await withAdminAccess(WaitlistDashboard);
return <ProtectedWaitlistDashboard />;
}

View File

@@ -1,38 +1,32 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/molecules/Alert/Alert";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers";
import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
import { useAgentMissingCredentials } from "./hooks/useAgentMissingCredentials";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
@@ -51,6 +45,7 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
@@ -68,10 +63,6 @@ export function NewAgentLibraryView() {
} = useMarketplaceUpdate({ agent });
const [changelogOpen, setChangelogOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const { hasMissingCredentials, isLoading: isLoadingCredentials } =
useAgentMissingCredentials(agent);
useEffect(() => {
if (agent) {
@@ -146,33 +137,13 @@ export function NewAgentLibraryView() {
return (
<>
<div className="flex h-full flex-col">
<div className="mx-6 flex flex-col gap-4 pt-4">
<div className="flex items-center justify-between">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
<AgentSettingsModal agent={agent} />
</div>
{hasMissingCredentials && !isLoadingCredentials && (
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
)}
<div className="mx-6 pt-4">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
@@ -183,13 +154,6 @@ export function NewAgentLibraryView() {
/>
</div>
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>
@@ -200,49 +164,37 @@ export function NewAgentLibraryView() {
<>
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block">
{hasMissingCredentials && !isLoadingCredentials && (
<div className={cn("mb-4", AGENT_LIBRARY_SECTION_PADDING_X)}>
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
</div>
)}
<div
className={cn(
"border-b border-zinc-100 pb-5",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<div className="flex items-center gap-2">
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="flex-1"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<AgentSettingsButton
agent={agent}
onSelectSettings={handleSelectSettings}
selected={activeItem === "settings"}
/>
</div>
</div>
<SidebarRunsList
@@ -256,7 +208,12 @@ export function NewAgentLibraryView() {
</SectionWrap>
{activeItem ? (
activeTab === "scheduled" ? (
activeItem === "settings" ? (
<SelectedSettingsView
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
<SelectedScheduleView
agent={agent}
scheduleId={activeItem}
@@ -289,6 +246,8 @@ export function NewAgentLibraryView() {
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()}
onSelectSettings={handleSelectSettings}
selectedSettings={activeItem === "settings"}
/>
)
) : sidebarLoading ? (
@@ -328,13 +287,6 @@ export function NewAgentLibraryView() {
</SelectedViewLayout>
)}
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>

View File

@@ -4,7 +4,6 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { isSystemCredential } from "../CredentialsInputs/helpers";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
@@ -72,7 +71,6 @@ export function AgentInputsReadOnly({
{credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
if (isSystemCredential(credential)) return null;
return (
<CredentialsInput

View File

@@ -1,131 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { GearIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { useAgentSystemCredentials } from "../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "../../selected-views/SelectedSettingsView/components/SystemCredentialRow";
interface Props {
agent: LibraryAgent;
controlledOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AgentSettingsModal({
agent,
controlledOpen,
onOpenChange,
}: Props) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
function setIsOpen(open: boolean) {
if (onOpenChange) {
onOpenChange(open);
} else {
setInternalIsOpen(open);
}
}
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const { hasSystemCredentials, systemCredentials } =
useAgentSystemCredentials(agent);
// Only show credential fields that have system credentials
const credentialFieldsWithSystemCreds = useMemo(() => {
return systemCredentials.map((item) => ({
fieldKey: item.key,
schema: item.schema,
systemCredential: item.credential,
}));
}, [systemCredentials]);
const hasAnySettings = hasHITLBlocks || hasSystemCredentials;
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
title="Agent Settings"
>
{controlledOpen === undefined && (
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
</Dialog.Trigger>
)}
<Dialog.Content>
<div className="space-y-6">
{hasHITLBlocks && (
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
<Text variant="large-semibold">Require human approval</Text>
<Text variant="large" className="mt-1 text-zinc-900">
The agent will pause and wait for your review before
continuing
</Text>
</div>
<Switch
checked={currentSafeMode || false}
onCheckedChange={handleToggle}
disabled={isPending}
className="mt-1"
/>
</div>
</div>
)}
{hasSystemCredentials && (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent
to access various services. You can switch to your own
credentials if preferred.
</Text>
</div>
<div className="w-full space-y-4">
{credentialFieldsWithSystemCreds.map(
({ fieldKey, schema, systemCredential }) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={schema}
systemCredential={systemCredential}
/>
),
)}
</div>
</div>
)}
{!hasAnySettings && (
<div className="py-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -10,10 +10,11 @@ import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { isSystemCredential } from "./helpers";
import { getCredentialDisplayName } from "./helpers";
import {
CredentialsInputState,
useCredentialsInput,
@@ -36,7 +37,6 @@ type Props = {
isOptional?: boolean;
showTitle?: boolean;
variant?: "default" | "node";
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function CredentialsInput({
@@ -50,7 +50,6 @@ export function CredentialsInput({
isOptional = false,
showTitle = true,
variant = "default",
allowSystemCredentials = false,
}: Props) {
const hookData = useCredentialsInput({
schema,
@@ -60,7 +59,6 @@ export function CredentialsInput({
onLoaded,
readOnly,
isOptional,
allowSystemCredentials,
});
if (!isLoaded(hookData)) {
@@ -81,22 +79,21 @@ export function CredentialsInput({
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
const selectedCredentialIsSystem =
selectedCredential && isSystemCredential(selectedCredential);
if (readOnly && selectedCredentialIsSystem) {
return null;
}
return (
<div className={cn("mb-6", className)}>
@@ -140,6 +137,15 @@ export function CredentialsInput({
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
@@ -223,6 +229,13 @@ export function CredentialsInput({
Error: {oAuthError}
</Text>
) : null}
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</>
)}
</div>

View File

@@ -1,11 +1,11 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormDescription,
FormField,
} from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
@@ -60,10 +60,7 @@ export function APIKeyCredentialsModal({
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 px-2"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="apiKey"
@@ -73,7 +70,8 @@ export function APIKeyCredentialsModal({
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API Key..."
placeholder="Enter API key..."
size="small"
hint={
schema.credentials_scopes ? (
<FormDescription>
@@ -100,7 +98,8 @@ export function APIKeyCredentialsModal({
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API Key..."
placeholder="Enter a name for this API key..."
size="small"
{...field}
/>
)}
@@ -114,12 +113,13 @@ export function APIKeyCredentialsModal({
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
/>
)}
/>
<Button type="submit" className="min-w-68">
Add API Key
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
</Button>
</form>
</Form>

View File

@@ -26,7 +26,7 @@ type CredentialRowProps = {
provider: string;
displayName: string;
onSelect: () => void;
onDelete?: () => void;
onDelete: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
@@ -100,7 +100,7 @@ export function CredentialRow({
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && onDelete && (
{!readOnly && !showCaret && !asSelectTrigger && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button

View File

@@ -65,7 +65,7 @@ export function CredentialsSelect({
>
<SelectTrigger
className={cn(
"h-auto min-h-12 w-full rounded-medium p-0 pr-4 shadow-none",
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden",
)}
>
@@ -87,39 +87,6 @@ export function CredentialsSelect({
variant={variant}
/>
</SelectValue>
) : allowNone ? (
<SelectValue key="__none__" asChild>
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
variant === "node"
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: "border-0 bg-transparent",
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-200">
<Text
variant="body"
className="text-xs font-medium text-zinc-500"
>
</Text>
</div>
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
variant === "node" && "overflow-hidden",
)}
>
<Text
variant="body"
className={cn("tracking-tight text-zinc-500")}
>
None (skip this credential)
</Text>
</div>
</div>
</SelectValue>
) : (
<SelectValue key="placeholder" placeholder="Select credential" />
)}

View File

@@ -100,29 +100,3 @@ export function getCredentialDisplayName(
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;
export function isSystemCredential(credential: {
title?: string | null;
is_system?: boolean;
}): boolean {
if (credential.is_system === true) return true;
if (!credential.title) return false;
const titleLower = credential.title.toLowerCase();
return (
titleLower.includes("system") ||
titleLower.startsWith("use credits for") ||
titleLower.includes("use credits")
);
}
export function filterSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => !isSystemCredential(cred));
}
export function getSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => isSystemCredential(cred));
}

View File

@@ -6,11 +6,9 @@ import {
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
filterSystemCredentials,
getActionButtonText,
getSystemCredentials,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
@@ -25,7 +23,6 @@ type Params = {
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
isOptional?: boolean;
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function useCredentialsInput({
@@ -36,7 +33,6 @@ export function useCredentialsInput({
onLoaded,
readOnly = false,
isOptional = false,
allowSystemCredentials = false,
}: Params) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
@@ -58,7 +54,6 @@ export function useCredentialsInput({
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const hasAttemptedAutoSelect = useRef(false);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
@@ -87,22 +82,13 @@ export function useCredentialsInput({
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
const availableCreds = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
if (
selectedCredential &&
!availableCreds.some((c) => c.id === selectedCredential.id)
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
) {
onSelectCredential(undefined);
}
}, [
credentials,
selectedCredential,
onSelectCredential,
readOnly,
allowSystemCredentials,
]);
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
// The available credential, if there is only one
const singleCredential = useMemo(() => {
@@ -110,111 +96,24 @@ export function useCredentialsInput({
return null;
}
const credsToUse = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
return credsToUse.length === 1 ? credsToUse[0] : null;
}, [credentials, allowSystemCredentials]);
return credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
}, [credentials]);
// Auto-select the one available credential
// Prioritize system credentials if available
// For system credentials, always auto-select even if optional (they should be used by default)
// Auto-select the one available credential (only if not optional)
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
// Early return if already selected to prevent infinite loops
const currentSelectedId = selectedCredential?.id;
if (currentSelectedId) {
hasAttemptedAutoSelect.current = true;
return;
}
// If selectedCredential is explicitly undefined and isOptional is true,
// don't auto-select - this could mean "None" was explicitly selected
// The parent component should handle setting the initial value
if (selectedCredential === undefined && isOptional) {
// Mark as attempted to prevent auto-selection when "None" is a valid choice
hasAttemptedAutoSelect.current = true;
return;
}
// Only attempt auto-selection once per credential load
if (hasAttemptedAutoSelect.current) return;
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
const savedCreds = credentials.savedCredentials;
const systemCreds = getSystemCredentials(savedCreds);
// Filter system credentials by type and scopes (same logic as useCredentials)
const matchingSystemCreds = systemCreds.filter((cred) => {
// Check type match
if (!supportedTypes.includes(cred.type)) {
return false;
}
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) {
return false;
}
}
return true;
});
// First, try to auto-select system credential if available
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
const credProvider = credentials.provider;
hasAttemptedAutoSelect.current = true;
onSelectCredential({
id: systemCred.id,
type: systemCred.type,
provider: credProvider,
title: (systemCred as any).title,
});
return;
}
// Otherwise, auto-select single credential if there's only one (and not optional)
if (!isOptional && singleCredential) {
hasAttemptedAutoSelect.current = true;
if (isOptional) return; // Don't auto-select when credential is optional
if (singleCredential && !selectedCredential) {
onSelectCredential(singleCredential);
}
}, [
singleCredential?.id, // Only depend on the ID, not the whole object
selectedCredential?.id, // Only depend on the ID, not the whole object
singleCredential,
selectedCredential,
onSelectCredential,
readOnly,
isOptional,
credentials,
schema.credentials_types,
schema.credentials_scopes,
// Note: onSelectCredential removed from deps to prevent infinite loops
// It should be stable, but if it's not, the ref will prevent multiple calls
]);
// Reset the ref when credentials change significantly
useEffect(() => {
if (credentials && "savedCredentials" in credentials) {
hasAttemptedAutoSelect.current = false;
}
}, [
credentials && "savedCredentials" in credentials
? credentials.savedCredentials.length
: 0,
credentials && "savedCredentials" in credentials
? credentials.provider
: null,
]);
if (
@@ -238,11 +137,6 @@ export function useCredentialsInput({
oAuthCallback,
} = credentials;
// Filter system credentials unless explicitly allowed (for settings)
const filteredCredentials = allowSystemCredentials
? savedCredentials
: filterSystemCredentials(savedCredentials);
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
@@ -397,7 +291,7 @@ export function useCredentialsInput({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow: filteredCredentials,
credentialsToShow: savedCredentials,
selectedCredential,
oAuthError,
isAPICredentialsModalOpen,
@@ -412,7 +306,7 @@ export function useCredentialsInput({
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
filteredCredentials.length > 0,
savedCredentials.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,

View File

@@ -12,7 +12,7 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
@@ -82,8 +82,6 @@ export function RunAgentModal({
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 ||
@@ -91,43 +89,6 @@ export function RunAgentModal({
const isTriggerRunType = defaultRunType.includes("trigger");
useEffect(() => {
if (!isOpen) return;
function checkOverflow() {
if (!contentRef.current) return;
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
setHasOverflow(
scrollableParent.scrollHeight > scrollableParent.clientHeight,
);
}
}
const timeoutId = setTimeout(checkOverflow, 100);
const resizeObserver = new ResizeObserver(checkOverflow);
if (contentRef.current) {
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
resizeObserver.observe(scrollableParent);
}
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [
isOpen,
hasAnySetupFields,
agentInputFields,
agentCredentialsInputFields,
]);
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -173,97 +134,91 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
<div ref={contentRef} className="flex min-h-full flex-col">
<div className="flex-1">
{/* Header */}
<ModalHeader agent={agent} />
{/* Header */}
<ModalHeader agent={agent} />
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10 pb-32">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
<Dialog.Footer
className={`sticky bottom-0 z-10 bg-white pt-4 ${
hasOverflow
? "border-t border-neutral-100 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
: ""
}`}
>
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials
before scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={isExecuting || isSettingUpTrigger}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials before
scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</Dialog.Footer>
</div>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>

View File

@@ -1,16 +1,6 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useContext, useMemo } from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
isSystemCredential,
} from "../../../CredentialsInputs/helpers";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
@@ -32,44 +22,8 @@ export function ModalRunSection() {
agentCredentialsInputFields,
} = useRunAgentModalContext();
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
const inputFields = Object.entries(agentInputFields || {});
// Only show credential fields that have user credentials (NOT system credentials)
// System credentials should only be shown in settings, not in run modal
const credentialFields = useMemo(() => {
if (!allProviders || !agentCredentialsInputFields) return [];
return Object.entries(agentCredentialsInputFields).filter(
([_key, schema]) => {
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has user credentials (NOT system credentials)
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, show this field
if (matchingUserCreds.length > 0) {
return true;
}
}
// Hide the field if only system credentials exist (or no credentials at all)
return false;
},
);
}, [agentCredentialsInputFields, allProviders]);
const credentialFields = Object.entries(agentCredentialsInputFields || {});
// Get the list of required credentials from the schema
const requiredCredentials = new Set(
@@ -144,113 +98,22 @@ export function ModalRunSection() {
subtitle="These are the credentials the agent will use to perform this task"
>
<div className="space-y-6">
{credentialFields
.map(([key, inputSubSchema]) => {
const selectedCred = inputCredentials?.[key];
// Check if the selected credential is a system credential
// First check the credential object itself, then look it up in providers
let isSystemCredSelected = false;
if (selectedCred) {
// Check if credential object has is_system or title indicates system credential
isSystemCredSelected = isSystemCredential(
selectedCred as { title?: string; is_system?: boolean },
);
// If not detected by title/is_system, check by looking up in providers
if (
!isSystemCredSelected &&
selectedCred.id &&
allProviders
) {
const providerNames =
inputSubSchema.credentials_provider || [];
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = providerData.savedCredentials.filter(
(cred: any) => cred.is_system === true,
);
if (
systemCreds.some(
(cred: any) => cred.id === selectedCred.id,
)
) {
isSystemCredSelected = true;
break;
}
}
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
}
// If a system credential is selected, check if there are user credentials available
// If not, hide this field entirely (it will still be used for execution)
if (isSystemCredSelected) {
const providerNames =
inputSubSchema.credentials_provider || [];
const supportedTypes = inputSubSchema.credentials_types || [];
const hasUserCreds = providerNames.some(
(providerName: string) => {
const providerData = allProviders?.[providerName];
if (!providerData) return false;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
return userCreds.some((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
},
);
// If no user credentials available, hide the field completely
if (!hasUserCreds) {
return null;
selectedCredentials={inputCredentials?.[key]}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
}
// If a system credential is selected but user creds exist, don't show it in the UI
// (it will still be used for execution, but user can select a user credential instead)
const credToDisplay = isSystemCredSelected
? undefined
: selectedCred;
return (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={credToDisplay}
onSelectCredentials={(value) => {
// When user selects a credential, update the state and save to preferences
setInputCredentialsValue(key, value);
// Save to preferences store
if (value === undefined) {
store.setCredentialPreference(
agent.id.toString(),
key,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
store.setCredentialPreference(
agent.id.toString(),
key,
null,
);
} else {
store.setCredentialPreference(
agent.id.toString(),
key,
value,
);
}
}}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
);
})
.filter(Boolean)}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
),
)}
</div>
</ModalSection>
) : null}

View File

@@ -11,25 +11,9 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../CredentialsInputs/helpers";
import { useCallback, useEffect, useMemo, useState } from "react";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
@@ -58,10 +42,8 @@ export function useAgentRunModal(
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {},
);
const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>("");
const hasInitializedSystemCreds = useRef(false);
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
@@ -76,198 +58,6 @@ export function useAgentRunModal(
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
// Initialize credentials from saved preferences or default system credentials
// This ensures credentials are used even when the field is not displayed
useEffect(() => {
if (!allProviders || !agent.credentials_input_schema?.properties) return;
if (callbacks?.initialInputCredentials) {
hasInitializedSystemCreds.current = true;
return; // Don't override if initial credentials provided
}
if (hasInitializedSystemCreds.current) return; // Already initialized
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
// Use functional update to get current state and avoid stale closures
setInputCredentials((currentCreds) => {
const credsToAdd: Record<string, any> = {};
for (const [key, schema] of Object.entries(properties)) {
// Skip if already set
if (currentCreds[key]) continue;
// First, check if user has a saved preference
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// Check if "None" was explicitly selected (special marker)
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - don't add any credential
continue;
}
if (savedPreference) {
credsToAdd[key] = savedPreference;
continue;
}
// Otherwise, find default system credentials for this field
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's exactly one system credential, use it as default
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
credsToAdd[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break; // Use first matching provider
}
}
}
// Only update if we found credentials to add
if (Object.keys(credsToAdd).length > 0) {
hasInitializedSystemCreds.current = true;
return {
...currentCreds,
...credsToAdd,
};
}
return currentCreds; // No changes
});
}, [
allProviders,
agent.credentials_input_schema,
agent.id,
store,
callbacks?.initialInputCredentials,
]);
// Sync credentials with preferences store when modal opens
useEffect(() => {
if (!isOpen || !allProviders || !agent.credentials_input_schema?.properties)
return;
if (callbacks?.initialInputCredentials) return; // Don't override if initial credentials provided
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
setInputCredentials((currentCreds) => {
const updatedCreds: Record<string, any> = { ...currentCreds };
for (const [key, schema] of Object.entries(properties)) {
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - remove from credentials
delete updatedCreds[key];
} else if (savedPreference) {
// User has a saved preference - use it
updatedCreds[key] = savedPreference;
} else if (!updatedCreds[key]) {
// No preference and no current credential - try to find default system credential
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
updatedCreds[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break;
}
}
}
}
return updatedCreds;
});
}, [
isOpen,
agent.id,
agent.credentials_input_schema,
allProviders,
store,
callbacks?.initialInputCredentials,
]);
// Reset initialization flag when modal closes/opens or agent changes
useEffect(() => {
hasInitializedSystemCreds.current = false;
}, [isOpen, agent.graph_id]);
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
@@ -379,70 +169,15 @@ export function useAgentRunModal(
(agent.credentials_input_schema?.required as string[]) || [],
);
// Filter out credential fields that only have system credentials available
// System credentials should not be required in the run modal
// Also check if user has a saved preference (including NONE_MARKER)
const requiredCredentialsToCheck = [...requiredCredentials].filter(
(key) => {
// Check if user has a saved preference first
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// If "None" was explicitly selected, don't require it
if (savedPreference === NONE_CREDENTIAL_MARKER) {
return false;
}
// If user has a saved preference, it should be checked
if (savedPreference) {
return true;
}
const schema = agentCredentialsInputFields[key];
if (!schema || !allProviders) return true; // If we can't check, include it
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has non-system credentials available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, this field should be checked
if (matchingUserCreds.length > 0) {
return true;
}
}
// If only system credentials are available, exclude from required check
return false;
},
);
// Check if required credentials have valid id (not just key existence)
// A credential is valid only if it has an id field set
const missing = requiredCredentialsToCheck.filter((key) => {
const missing = [...requiredCredentials].filter((key) => {
const cred = inputCredentials[key];
return !cred || !cred.id;
});
return [missing.length === 0, missing];
}, [
agent.credentials_input_schema,
agentCredentialsInputFields,
inputCredentials,
allProviders,
agent.id,
store,
]);
}, [agent.credentials_input_schema, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,

View File

@@ -1,17 +1,37 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { GearIcon } from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
interface Props {
agent: LibraryAgent;
onSelectSettings: () => void;
selected?: boolean;
}
export function AgentSettingsButton({
agent,
onSelectSettings,
selected,
}: Props) {
const { hasHITLBlocks } = useAgentSafeMode(agent);
if (!hasHITLBlocks) {
return null;
}
export function AgentSettingsButton() {
return (
<Button
variant="ghost"
variant={selected ? "secondary" : "ghost"}
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
onClick={onSelectSettings}
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
<GearIcon
size={18}
className={selected ? "text-zinc-900" : "text-zinc-600"}
/>
</Button>
);
}

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptySchedules() {

View File

@@ -20,7 +20,6 @@ import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useAgentMissingCredentials } from "../../hooks/useAgentMissingCredentials";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
@@ -45,7 +44,6 @@ export function EmptyTasks({
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
const { hasMissingCredentials } = useAgentMissingCredentials(agent);
async function handleDeleteAgent() {
if (!agent.id) return;
@@ -126,7 +124,6 @@ export function EmptyTasks({
variant="primary"
size="large"
className="inline-flex w-[19.75rem]"
disabled={hasMissingCredentials}
>
Setup your task
</Button>

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTemplates() {

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {

View File

@@ -1,5 +1,3 @@
"use client";
import { cn } from "@/lib/utils";
type Props = {

View File

@@ -1,16 +1,22 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { SelectedViewLayout } from "./SelectedViewLayout";
interface Props {
agent: LibraryAgent;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function LoadingSelectedContent(props: Props) {
return (
<SelectedViewLayout agent={props.agent}>
<SelectedViewLayout
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selectedSettings={props.selectedSettings}
>
<div
className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)}
>

View File

@@ -33,6 +33,8 @@ interface Props {
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedRunView({
@@ -41,6 +43,8 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
@@ -80,7 +84,12 @@ export function SelectedRunView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent} banner={banner}>
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />

View File

@@ -21,6 +21,8 @@ interface Props {
scheduleId: string;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedScheduleView({
@@ -28,6 +30,8 @@ export function SelectedScheduleView({
scheduleId,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { schedule, isLoading, error } = useSelectedScheduleView(
agent.graph_id,
@@ -72,7 +76,12 @@ export function SelectedScheduleView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent} banner={banner}>
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader

View File

@@ -1,12 +1,11 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SystemCredentialsSection } from "./components/SystemCredentialsSection";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
interface Props {
agent: LibraryAgent;
@@ -17,12 +16,8 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const hasCredentialsSchema =
agent.credentials_input_schema &&
Object.keys(agent.credentials_input_schema.properties || {}).length > 0;
return (
<SelectedViewLayout agent={agent}>
<SelectedViewLayout agent={agent} onSelectSettings={() => {}}>
<div className="flex flex-col gap-4">
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`}
@@ -38,8 +33,15 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
<Text variant="h2">Agent Settings</Text>
</div>
<div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
{hasHITLBlocks && (
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
{!hasHITLBlocks ? (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any human-in-the-loop blocks, so
there are no settings to configure.
</Text>
</div>
) : (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
@@ -58,16 +60,6 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
</div>
</div>
)}
{hasCredentialsSchema && <SystemCredentialsSection agent={agent} />}
{!hasHITLBlocks && !hasCredentialsSchema && (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</div>
</SelectedViewLayout>

View File

@@ -1,99 +0,0 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useEffect, useState } from "react";
import { CredentialsInput } from "../../../../components/modals/CredentialsInputs/CredentialsInputs";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../stores/agentCredentialPreferencesStore";
interface Props {
credentialKey: string;
agentId: string;
schema: any;
systemCredential: CredentialsMetaResponse;
}
export function SystemCredentialRow({
credentialKey,
agentId,
schema,
systemCredential,
}: Props) {
const store = useAgentCredentialPreferencesStore();
// Initialize with saved preference or default to system credential
const savedPreference = store.getCredentialPreference(agentId, credentialKey);
const defaultCredential = {
id: systemCredential.id,
type: systemCredential.type,
provider: systemCredential.provider,
title: systemCredential.title,
};
// If saved preference is the NONE marker, use undefined (which CredentialsInput interprets as "None")
// Otherwise use saved preference or default
const [selectedCredential, setSelectedCredential] = useState<any>(
savedPreference === NONE_CREDENTIAL_MARKER
? undefined
: savedPreference || defaultCredential,
);
// Update when preference changes externally
useEffect(() => {
const preference = store.getCredentialPreference(agentId, credentialKey);
if (preference === NONE_CREDENTIAL_MARKER) {
setSelectedCredential(undefined);
} else if (preference) {
setSelectedCredential(preference);
} else {
setSelectedCredential(defaultCredential);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentialKey, agentId]);
const providerName = schema.credentials_provider?.[0] || "";
const displayName = toDisplayName(providerName);
function handleSelectCredentials(value: any) {
setSelectedCredential(value);
// Save preference:
// - undefined = explicitly selected "None" (save NONE_CREDENTIAL_MARKER)
// - null = use default system credential (fallback behavior, save null)
// - credential object = use this specific credential
if (value === undefined) {
// User explicitly selected "None" - save special marker
store.setCredentialPreference(
agentId,
credentialKey,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
// User cleared selection - use default system credential
store.setCredentialPreference(agentId, credentialKey, null);
} else {
// User selected a credential
store.setCredentialPreference(agentId, credentialKey, value);
}
}
return (
<div className="rounded-lg border border-zinc-100 bg-zinc-50/50 px-4 pb-2 pt-4">
<Text variant="body-medium" className="mb-2 ml-2">
{displayName}
</Text>
<CredentialsInput
schema={{ ...schema, discriminator: undefined }}
selectedCredentials={selectedCredential}
onSelectCredentials={handleSelectCredentials}
showTitle={false}
isOptional
allowSystemCredentials={true}
/>
</div>
);
}

View File

@@ -1,63 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSystemCredentials } from "../../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "./SystemCredentialRow";
interface Props {
agent: LibraryAgent;
}
export function SystemCredentialsSection({ agent }: Props) {
const { hasSystemCredentials, systemCredentials, isLoading } =
useAgentSystemCredentials(agent);
if (isLoading) {
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="text-muted-foreground">
Loading credentials...
</Text>
</div>
);
}
if (!hasSystemCredentials) return null;
// Group by credential field key (from schema) to show one row per field
const credentialsByField = systemCredentials.reduce(
(acc, item) => {
if (!acc[item.key]) {
acc[item.key] = item;
}
return acc;
},
{} as Record<string, (typeof systemCredentials)[0]>,
);
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent to
access various services. You can switch to your own credentials if
preferred.
</Text>
</div>
<div className="w-full space-y-4">
{Object.entries(credentialsByField).map(([fieldKey, item]) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={item.schema}
systemCredential={item.credential}
/>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AgentSettingsButton } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { AgentSettingsModal } from "../modals/AgentSettingsModal/AgentSettingsModal";
import { SectionWrap } from "../other/SectionWrap";
interface Props {
@@ -9,6 +9,8 @@ interface Props {
children: React.ReactNode;
banner?: React.ReactNode;
additionalBreadcrumb?: { name: string; link?: string };
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedViewLayout(props: Props) {
@@ -17,8 +19,8 @@ export function SelectedViewLayout(props: Props) {
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
>
{props.banner}
<div className="relative flex w-full items-center justify-between">
{props.banner && <div className="mb-4">{props.banner}</div>}
<div className="relative flex w-fit items-center gap-2">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
@@ -31,9 +33,15 @@ export function SelectedViewLayout(props: Props) {
: []),
]}
/>
<div className="absolute right-0">
<AgentSettingsModal agent={props.agent} />
</div>
{props.agent && props.onSelectSettings && (
<div className="absolute -right-8">
<AgentSettingsButton
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selected={props.selectedSettings}
/>
</div>
)}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible">

View File

@@ -1,109 +0,0 @@
"use client";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { useCallback, useEffect, useState } from "react";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type AgentCredentialPreferences = Record<
string,
CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER
>;
const STORAGE_KEY_PREFIX = "agent_credential_prefs_";
function getStorageKey(agentId: string): string {
return `${STORAGE_KEY_PREFIX}${agentId}`;
}
function loadPreferences(agentId: string): AgentCredentialPreferences {
const key = getStorageKey(agentId);
const stored = storage.get(key as any);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
// Convert serialized NONE markers back to the constant
const result: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(parsed)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true
) {
result[key] = NONE_CREDENTIAL_MARKER;
} else {
result[key] = value as CredentialsMetaInput | null;
}
}
return result;
} catch {
return {};
}
}
function savePreferences(
agentId: string,
preferences: AgentCredentialPreferences,
): void {
const key = getStorageKey(agentId);
storage.set(key as any, JSON.stringify(preferences));
}
export function useAgentCredentialPreferences(agentId: string) {
const [preferences, setPreferences] = useState<AgentCredentialPreferences>(
() => loadPreferences(agentId),
);
useEffect(() => {
const loaded = loadPreferences(agentId);
setPreferences(loaded);
}, [agentId]);
const setCredentialPreference = useCallback(
(
credentialKey: string,
credential: CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER,
) => {
setPreferences((prev) => {
const updated = {
...prev,
[credentialKey]: credential,
};
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
const getCredentialPreference = useCallback(
(
credentialKey: string,
): CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER => {
return preferences[credentialKey] ?? null;
},
[preferences],
);
const clearPreference = useCallback(
(credentialKey: string) => {
setPreferences((prev) => {
const updated = { ...prev };
delete updated[credentialKey];
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
return {
preferences,
setCredentialPreference,
getCredentialPreference,
clearPreference,
};
}

View File

@@ -1,105 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import { getSystemCredentials } from "../components/modals/CredentialsInputs/helpers";
/**
* Hook to check if an agent is missing required SYSTEM credentials.
* This is only used to block "New Task" buttons.
* User credential validation is handled separately in RunAgentModal.
*/
export function useAgentMissingCredentials(
agent: LibraryAgent | null | undefined,
) {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
if (
!agent ||
!agent.id ||
!allProviders ||
!agent.credentials_input_schema?.properties
) {
return {
hasMissingCredentials: false,
missingCredentials: [],
isLoading: !allProviders || !agent,
};
}
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const missingCredentials: Array<{
key: string;
providerDisplayName: string;
}> = [];
for (const [key, schema] of Object.entries(properties)) {
const isRequired = requiredCredentials.has(key);
if (!isRequired) continue; // Only check required credentials
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
let hasSystemCredential = false;
// Check if any provider has a system credential available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's a system credential available, it's not missing
if (matchingSystemCreds.length > 0) {
hasSystemCredential = true;
break;
}
}
// If no system credential available, mark as missing
if (!hasSystemCredential) {
const providerName = providerNames[0] || "";
missingCredentials.push({
key,
providerDisplayName: toDisplayName(providerName),
});
}
}
return {
hasMissingCredentials: missingCredentials.length > 0,
missingCredentials,
isLoading: false,
};
}, [allProviders, agent?.credentials_input_schema, agent?.id]);
return result;
}

View File

@@ -1,130 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import {
CredentialsProviderData,
CredentialsProvidersContext,
} from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../components/modals/CredentialsInputs/helpers";
interface SystemCredentialInfo {
key: string;
provider: string;
schema: any;
credential: CredentialsMetaResponse;
}
interface MissingCredentialInfo {
key: string;
provider: string;
providerDisplayName: string;
}
interface UseAgentSystemCredentialsResult {
hasSystemCredentials: boolean;
systemCredentials: SystemCredentialInfo[];
hasMissingSystemCredentials: boolean;
missingSystemCredentials: MissingCredentialInfo[];
isLoading: boolean;
}
export function useAgentSystemCredentials(
agent: LibraryAgent,
): UseAgentSystemCredentialsResult {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
const empty = {
hasSystemCredentials: false,
systemCredentials: [],
hasMissingSystemCredentials: false,
missingSystemCredentials: [],
isLoading: false,
};
if (!agent.credentials_input_schema?.properties) return empty;
if (!allProviders) return { ...empty, isLoading: true };
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const systemCredentials: SystemCredentialInfo[] = [];
const missingSystemCredentials: MissingCredentialInfo[] = [];
for (const [key, schema] of Object.entries(properties)) {
const providerNames = schema.credentials_provider || [];
const isRequired = requiredCredentials.has(key);
const supportedTypes = schema.credentials_types || [];
for (const providerName of providerNames) {
const providerData: CredentialsProviderData | undefined =
allProviders[providerName];
if (!providerData) {
// Provider not loaded yet - don't mark as missing, wait for load
continue;
}
// Check for system credentials - now backend always returns them with is_system: true
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// Add system credentials if they exist (even if not configured, backend returns them)
for (const cred of matchingSystemCreds) {
systemCredentials.push({
key,
provider: providerName,
schema,
credential: cred,
});
}
// Only mark as missing if it's required AND there are NO credentials available
// (neither system nor user). This is a true "missing" state.
// Note: We don't block based on this anymore since the run modal
// has its own validation (allRequiredInputsAreSet)
if (
isRequired &&
matchingSystemCreds.length === 0 &&
matchingUserCreds.length === 0
) {
missingSystemCredentials.push({
key,
provider: providerName,
providerDisplayName: toDisplayName(providerName),
});
}
}
}
return {
hasSystemCredentials: systemCredentials.length > 0,
systemCredentials,
hasMissingSystemCredentials: missingSystemCredentials.length > 0,
missingSystemCredentials,
isLoading: false,
};
}, [agent.credentials_input_schema, allProviders]);
return result;
}

View File

@@ -1,135 +0,0 @@
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type CredentialPreference =
| CredentialsMetaInput
| null
| typeof NONE_CREDENTIAL_MARKER;
type AgentCredentialPreferences = Record<string, CredentialPreference>;
interface AgentCredentialPreferencesStore {
preferences: Record<string, AgentCredentialPreferences>; // agentId -> preferences
setCredentialPreference: (
agentId: string,
credentialKey: string,
credential: CredentialPreference,
) => void;
getCredentialPreference: (
agentId: string,
credentialKey: string,
) => CredentialPreference;
clearPreference: (agentId: string, credentialKey: string) => void;
}
const STORAGE_KEY = "agent_credential_preferences";
// Custom storage adapter for localStorage
const customStorage = {
getItem: (name: string): string | null => {
return storage.get(name as any) || null;
},
setItem: (name: string, value: string): void => {
storage.set(name as any, value);
},
removeItem: (name: string): void => {
storage.clean(name as any);
},
};
export const useAgentCredentialPreferencesStore =
create<AgentCredentialPreferencesStore>()(
persist(
(set, get) => ({
preferences: {},
setCredentialPreference: (agentId, credentialKey, credential) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = {
...state.preferences,
[agentId]: {
...agentPrefs,
[credentialKey]: credential,
},
};
return { preferences: updated };
});
},
getCredentialPreference: (agentId, credentialKey) => {
const state = get();
const pref = state.preferences[agentId]?.[credentialKey];
// Convert serialized NONE marker back to constant
if (
pref &&
typeof pref === "object" &&
"__none__" in pref &&
(pref as any).__none__ === true &&
pref !== NONE_CREDENTIAL_MARKER
) {
return NONE_CREDENTIAL_MARKER;
}
return pref ?? null;
},
clearPreference: (agentId, credentialKey) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = { ...agentPrefs };
delete updated[credentialKey];
return {
preferences: {
...state.preferences,
[agentId]: updated,
},
};
});
},
}),
{
name: STORAGE_KEY,
storage: createJSONStorage(() => customStorage),
// Transform on rehydrate to convert NONE markers
onRehydrateStorage: () => (state, error) => {
if (error || !state) {
console.error("Failed to rehydrate credential preferences:", error);
return;
}
// Convert serialized NONE markers back to constant
const converted: Record<string, AgentCredentialPreferences> = {};
for (const [agentId, prefs] of Object.entries(
state.preferences || {},
)) {
const convertedPrefs: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(prefs)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true &&
value !== NONE_CREDENTIAL_MARKER
) {
convertedPrefs[key] = NONE_CREDENTIAL_MARKER;
} else {
convertedPrefs[key] = value as CredentialPreference;
}
}
converted[agentId] = convertedPrefs;
}
// Update state with converted preferences
if (
Object.keys(converted).length > 0 ||
Object.keys(state.preferences || {}).length > 0
) {
state.preferences = converted;
}
},
},
),
);

View File

@@ -8,6 +8,7 @@ import { useMainMarketplacePage } from "./useMainMarketplacePage";
import { FeaturedCreators } from "../FeaturedCreators/FeaturedCreators";
import { MainMarketplacePageLoading } from "../MainMarketplacePageLoading";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { WaitlistSection } from "../WaitlistSection/WaitlistSection";
export const MainMarkeplacePage = () => {
const { featuredAgents, topAgents, featuredCreators, isLoading, hasError } =
@@ -46,6 +47,10 @@ export const MainMarkeplacePage = () => {
{/* 100px margin because our featured sections button are placed 40px below the container */}
<Separator className="mb-6 mt-24" />
{/* Waitlist Section - "Help Shape What's Next" */}
<WaitlistSection />
<Separator className="mb-6 mt-12" />
{topAgents && (
<AgentsSection sectionTitle="Top Agents" agents={topAgents.agents} />
)}

View File

@@ -0,0 +1,105 @@
"use client";
import Image from "next/image";
import { Button } from "@/components/atoms/Button/Button";
import { Check } from "@phosphor-icons/react";
interface WaitlistCardProps {
name: string;
subHeading: string;
description: string;
imageUrl: string | null;
isMember?: boolean;
onCardClick: () => void;
onJoinClick: (e: React.MouseEvent) => void;
}
export function WaitlistCard({
name,
subHeading,
description,
imageUrl,
isMember = false,
onCardClick,
onJoinClick,
}: WaitlistCardProps) {
function handleJoinClick(e: React.MouseEvent) {
e.stopPropagation();
onJoinClick(e);
}
return (
<div
className="flex h-[24rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-white transition-all duration-300 hover:shadow-lg dark:bg-zinc-900 dark:hover:shadow-gray-700"
onClick={onCardClick}
data-testid="waitlist-card"
role="button"
tabIndex={0}
aria-label={`${name} waitlist card`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onCardClick();
}
}}
>
{/* Image Section */}
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-large md:aspect-[2.17/1]">
{imageUrl ? (
<Image
src={imageUrl}
alt={`${name} preview image`}
fill
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800">
<span className="text-4xl font-bold text-neutral-400 dark:text-neutral-500">
{name.charAt(0)}
</span>
</div>
)}
</div>
<div className="mt-3 flex w-full flex-1 flex-col px-4">
{/* Name and Subheading */}
<div className="flex w-full flex-col">
<h3 className="line-clamp-1 font-poppins text-xl font-semibold text-[#272727] dark:text-neutral-100">
{name}
</h3>
<p className="mt-1 line-clamp-1 text-sm text-neutral-500 dark:text-neutral-400">
{subHeading}
</p>
</div>
{/* Description */}
<div className="mt-2 flex w-full flex-col">
<p className="line-clamp-5 text-sm font-normal leading-relaxed text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
<div className="flex-grow" />
{/* Join Waitlist Button */}
<div className="mt-4 w-full pb-4">
{isMember ? (
<Button
disabled
className="w-full rounded-full bg-green-600 text-white hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-700"
>
<Check className="mr-2" size={16} weight="bold" />
On the waitlist
</Button>
) : (
<Button
onClick={handleJoinClick}
className="w-full rounded-full bg-zinc-800 text-white hover:bg-zinc-700 dark:bg-zinc-700 dark:hover:bg-zinc-600"
>
Join waitlist
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Input } from "@/components/atoms/Input/Input";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/__legacy__/ui/carousel";
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
import { Check, Play } from "@phosphor-icons/react";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { usePostV2AddSelfToTheAgentWaitlist } from "@/app/api/__generated__/endpoints/store/store";
interface MediaItem {
type: "image" | "video";
url: string;
label?: string;
}
function MediaCarousel({ waitlist }: { waitlist: StoreWaitlistEntry }) {
const [activeVideo, setActiveVideo] = useState<string | null>(null);
// Build media items array: videos first, then images
const mediaItems: MediaItem[] = [
...(waitlist.videoUrl
? [{ type: "video" as const, url: waitlist.videoUrl, label: "Video" }]
: []),
...(waitlist.agentOutputDemoUrl
? [
{
type: "video" as const,
url: waitlist.agentOutputDemoUrl,
label: "Demo",
},
]
: []),
...waitlist.imageUrls.map((url) => ({ type: "image" as const, url })),
];
if (mediaItems.length === 0) return null;
// Single item - no carousel needed
if (mediaItems.length === 1) {
const item = mediaItems[0];
return (
<div className="relative aspect-[350/196] w-full overflow-hidden rounded-large">
{item.type === "image" ? (
<Image
src={item.url}
alt={`${waitlist.name} preview`}
fill
className="object-cover"
/>
) : (
<video
src={item.url}
controls
className="h-full w-full object-cover"
/>
)}
</div>
);
}
// Multiple items - use carousel
return (
<Carousel className="w-full">
<CarouselContent>
{mediaItems.map((item, index) => (
<CarouselItem key={index}>
<div className="relative aspect-[350/196] w-full overflow-hidden rounded-large">
{item.type === "image" ? (
<Image
src={item.url}
alt={`${waitlist.name} preview ${index + 1}`}
fill
className="object-cover"
/>
) : activeVideo === item.url ? (
<video
src={item.url}
controls
autoPlay
className="h-full w-full object-cover"
/>
) : (
<button
onClick={() => setActiveVideo(item.url)}
className="group relative h-full w-full bg-zinc-900"
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 transition-transform group-hover:scale-110">
<Play size={32} weight="fill" className="text-zinc-800" />
</div>
</div>
<span className="absolute bottom-3 left-3 text-sm text-white">
{item.label}
</span>
</button>
)}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-2 top-1/2 -translate-y-1/2" />
<CarouselNext className="right-2 top-1/2 -translate-y-1/2" />
</Carousel>
);
}
interface WaitlistDetailModalProps {
waitlist: StoreWaitlistEntry;
isMember?: boolean;
onClose: () => void;
onJoinSuccess?: (waitlistId: string) => void;
}
export function WaitlistDetailModal({
waitlist,
isMember = false,
onClose,
onJoinSuccess,
}: WaitlistDetailModalProps) {
const { user } = useSupabaseStore();
const [email, setEmail] = useState("");
const [success, setSuccess] = useState(false);
const { toast } = useToast();
const joinWaitlistMutation = usePostV2AddSelfToTheAgentWaitlist();
function handleJoin() {
joinWaitlistMutation.mutate(
{
waitlistId: waitlist.waitlistId,
data: { email: user ? undefined : email },
},
{
onSuccess: (response) => {
if (response.status === 200) {
setSuccess(true);
toast({
title: "You're on the waitlist!",
description: `We'll notify you when ${waitlist.name} goes live.`,
});
onJoinSuccess?.(waitlist.waitlistId);
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to join waitlist. Please try again.",
});
}
},
onError: () => {
toast({
variant: "destructive",
title: "Error",
description: "Failed to join waitlist. Please try again.",
});
},
},
);
}
// Success state
if (success) {
return (
<Dialog
title=""
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "500px" }}
>
<Dialog.Content>
<div className="flex flex-col items-center justify-center py-4 text-center">
{/* Party emoji */}
<span className="mb-2 text-5xl">🎉</span>
{/* Title */}
<h2 className="mb-2 font-poppins text-[22px] font-medium leading-7 text-zinc-900 dark:text-zinc-100">
You&apos;re on the waitlist
</h2>
{/* Subtitle */}
<p className="text-base leading-[26px] text-zinc-600 dark:text-zinc-400">
Thanks for helping us prioritize which agents to build next.
We&apos;ll notify you when this agent goes live in the
marketplace.
</p>
</div>
{/* Close button */}
<Dialog.Footer className="flex justify-center pb-2 pt-4">
<Button
variant="secondary"
onClick={onClose}
className="rounded-full border border-zinc-700 bg-white px-4 py-3 text-zinc-900 hover:bg-zinc-100 dark:border-zinc-500 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
// Main modal - handles both member and non-member states
return (
<Dialog
title="Join the waitlist"
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "500px" }}
>
<Dialog.Content>
{/* Subtitle */}
<p className="mb-6 text-center text-base text-zinc-600 dark:text-zinc-400">
Help us decide what to build next and get notified when this agent
is ready
</p>
{/* Media Carousel */}
<MediaCarousel waitlist={waitlist} />
{/* Agent Name */}
<h3 className="mt-4 font-poppins text-[22px] font-medium leading-7 text-zinc-800 dark:text-zinc-100">
{waitlist.name}
</h3>
{/* Agent Description */}
<p className="mt-2 line-clamp-5 text-sm leading-[22px] text-zinc-500 dark:text-zinc-400">
{waitlist.description}
</p>
{/* Email input for non-logged-in users who haven't joined */}
{!isMember && !user && (
<div className="mt-4 pr-1">
<Input
id="email"
label="Email address"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
)}
{/* Footer buttons */}
<Dialog.Footer className="sticky bottom-0 mt-6 flex justify-center gap-3 bg-white pb-2 pt-4 dark:bg-zinc-900">
{isMember ? (
<Button
disabled
className="rounded-full bg-green-600 px-4 py-3 text-white hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-700"
>
<Check size={16} className="mr-2" />
You&apos;re on the waitlist
</Button>
) : (
<>
<Button
onClick={handleJoin}
loading={joinWaitlistMutation.isPending}
disabled={!user && !email}
className="rounded-full bg-zinc-800 px-4 py-3 text-white hover:bg-zinc-700 dark:bg-zinc-700 dark:hover:bg-zinc-600"
>
Join waitlist
</Button>
<Button
type="button"
variant="secondary"
onClick={onClose}
className="rounded-full bg-zinc-200 px-4 py-3 text-zinc-900 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-100 dark:hover:bg-zinc-600"
>
Not now
</Button>
</>
)}
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/__legacy__/ui/carousel";
import { WaitlistCard } from "../WaitlistCard/WaitlistCard";
import { WaitlistDetailModal } from "../WaitlistDetailModal/WaitlistDetailModal";
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
import { useWaitlistSection } from "./useWaitlistSection";
export function WaitlistSection() {
const { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined } =
useWaitlistSection();
const [selectedWaitlist, setSelectedWaitlist] =
useState<StoreWaitlistEntry | null>(null);
function handleOpenModal(waitlist: StoreWaitlistEntry) {
setSelectedWaitlist(waitlist);
}
function handleJoinSuccess(waitlistId: string) {
markAsJoined(waitlistId);
}
// Don't render if loading, error, or no waitlists
if (isLoading || hasError || !waitlists || waitlists.length === 0) {
return null;
}
return (
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-[1360px]">
{/* Section Header */}
<div className="mb-6">
<h2 className="font-poppins text-2xl font-semibold text-[#282828] dark:text-neutral-200">
Help Shape What&apos;s Next
</h2>
<p className="mt-2 text-base text-neutral-600 dark:text-neutral-400">
These agents are in development. Your interest helps us prioritize
what gets built and we&apos;ll notify you when they&apos;re ready.
</p>
</div>
{/* Mobile Carousel View */}
<Carousel
className="md:hidden"
opts={{
loop: true,
}}
>
<CarouselContent>
{waitlists.map((waitlist) => (
<CarouselItem
key={waitlist.waitlistId}
className="min-w-64 max-w-71"
>
<WaitlistCard
name={waitlist.name}
subHeading={waitlist.subHeading}
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
onCardClick={() => handleOpenModal(waitlist)}
onJoinClick={() => handleOpenModal(waitlist)}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
{/* Desktop Grid View */}
<div className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3">
{waitlists.map((waitlist) => (
<WaitlistCard
key={waitlist.waitlistId}
name={waitlist.name}
subHeading={waitlist.subHeading}
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
onCardClick={() => handleOpenModal(waitlist)}
onJoinClick={() => handleOpenModal(waitlist)}
/>
))}
</div>
</div>
{/* Single Modal for both viewing and joining */}
{selectedWaitlist && (
<WaitlistDetailModal
waitlist={selectedWaitlist}
isMember={joinedWaitlistIds.has(selectedWaitlist.waitlistId)}
onClose={() => setSelectedWaitlist(null)}
onJoinSuccess={handleJoinSuccess}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useMemo } from "react";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
import {
useGetV2GetTheAgentWaitlist,
useGetV2GetWaitlistIdsTheCurrentUserHasJoined,
} from "@/app/api/__generated__/endpoints/store/store";
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
import { useQueryClient } from "@tanstack/react-query";
export function useWaitlistSection() {
const { user } = useSupabaseStore();
const queryClient = useQueryClient();
// Fetch waitlists
const {
data: waitlistsResponse,
isLoading: waitlistsLoading,
isError: waitlistsError,
} = useGetV2GetTheAgentWaitlist();
// Fetch memberships if logged in
const { data: membershipsResponse, isLoading: membershipsLoading } =
useGetV2GetWaitlistIdsTheCurrentUserHasJoined({
query: {
enabled: !!user,
},
});
const waitlists: StoreWaitlistEntry[] = useMemo(() => {
if (waitlistsResponse?.status === 200) {
return waitlistsResponse.data.listings;
}
return [];
}, [waitlistsResponse]);
const joinedWaitlistIds: Set<string> = useMemo(() => {
if (membershipsResponse?.status === 200) {
return new Set(membershipsResponse.data);
}
return new Set();
}, [membershipsResponse]);
const isLoading = waitlistsLoading || (!!user && membershipsLoading);
const hasError = waitlistsError;
// Function to add a waitlist ID to joined set (called after successful join)
function markAsJoined(_waitlistId: string) {
// Invalidate the memberships query to refetch
queryClient.invalidateQueries({
queryKey: ["getV2GetWaitlistIdsTheCurrentUserHasJoined"],
});
}
return { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined };
}

View File

@@ -4965,6 +4965,301 @@
}
}
},
"/api/store/admin/waitlist": {
"get": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "List All Waitlists",
"description": "Get all waitlists with admin details (admin only).\n\nReturns:\n WaitlistAdminListResponse with all waitlists",
"operationId": "getV2List all waitlists",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminListResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
},
"post": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Create Waitlist",
"description": "Create a new waitlist (admin only).\n\nArgs:\n request: Waitlist creation details\n user_id: Authenticated admin user creating the waitlist\n\nReturns:\n WaitlistAdminResponse with the created waitlist details",
"operationId": "postV2Create waitlist",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/WaitlistCreateRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/store/admin/waitlist/{waitlist_id}": {
"delete": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Delete Waitlist",
"description": "Soft delete a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to delete\n\nReturns:\n Success message",
"operationId": "deleteV2Delete waitlist",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"get": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Get Waitlist Details",
"description": "Get a single waitlist with admin details (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to retrieve\n\nReturns:\n WaitlistAdminResponse with waitlist details",
"operationId": "getV2Get waitlist details",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"put": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Update Waitlist",
"description": "Update a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to update\n request: Fields to update\n\nReturns:\n WaitlistAdminResponse with updated waitlist details",
"operationId": "putV2Update waitlist",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/WaitlistUpdateRequest" }
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/admin/waitlist/{waitlist_id}/link": {
"post": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Link Waitlist to Store Listing",
"description": "Link a waitlist to a store listing (admin only).\n\nWhen the linked store listing is approved/published, waitlist users\nwill be automatically notified.\n\nArgs:\n waitlist_id: ID of the waitlist\n store_listing_id: ID of the store listing to link\n\nReturns:\n WaitlistAdminResponse with updated waitlist details",
"operationId": "postV2Link waitlist to store listing",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_postV2Link_waitlist_to_store_listing"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/admin/waitlist/{waitlist_id}/signups": {
"get": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Get Waitlist Signups",
"description": "Get all signups for a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist\n\nReturns:\n WaitlistSignupListResponse with all signups",
"operationId": "getV2Get waitlist signups",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistSignupListResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/agents": {
"get": {
"tags": ["v2", "store", "public"],
@@ -5747,6 +6042,101 @@
}
}
},
"/api/store/waitlist": {
"get": {
"tags": ["v2", "store", "public"],
"summary": "Get the agent waitlist",
"description": "Get all active waitlists for public display.",
"operationId": "getV2Get the agent waitlist",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreWaitlistsAllResponse"
}
}
}
}
}
}
},
"/api/store/waitlist/my-memberships": {
"get": {
"tags": ["v2", "store", "private"],
"summary": "Get waitlist IDs the current user has joined",
"description": "Returns list of waitlist IDs the authenticated user has joined.",
"operationId": "getV2Get waitlist ids the current user has joined",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": { "type": "string" },
"type": "array",
"title": "Response Getv2Get Waitlist Ids The Current User Has Joined"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/store/waitlist/{waitlist_id}/join": {
"post": {
"tags": ["v2", "store", "public"],
"summary": "Add self to the agent waitlist",
"description": "Add the current user to the agent waitlist.",
"operationId": "postV2Add self to the agent waitlist",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist to join",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist to join"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_postV2Add_self_to_the_agent_waitlist"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/StoreWaitlistEntry" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/health": {
"get": {
"tags": ["health"],
@@ -6494,6 +6884,17 @@
"required": ["store_listing_version_id"],
"title": "Body_postV2Add marketplace agent"
},
"Body_postV2Add_self_to_the_agent_waitlist": {
"properties": {
"email": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Email",
"description": "Email address for unauthenticated users"
}
},
"type": "object",
"title": "Body_postV2Add self to the agent waitlist"
},
"Body_postV2Execute_a_preset": {
"properties": {
"inputs": {
@@ -6512,6 +6913,18 @@
"type": "object",
"title": "Body_postV2Execute a preset"
},
"Body_postV2Link_waitlist_to_store_listing": {
"properties": {
"store_listing_id": {
"type": "string",
"title": "Store Listing Id",
"description": "The ID of the store listing"
}
},
"type": "object",
"required": ["store_listing_id"],
"title": "Body_postV2Link waitlist to store listing"
},
"Body_postV2Upload_submission_media": {
"properties": {
"file": { "type": "string", "format": "binary", "title": "File" }
@@ -6792,12 +7205,6 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Host",
"description": "Host pattern for host-scoped credentials"
},
"is_system": {
"type": "boolean",
"title": "Is System",
"description": "Whether this is a system-managed credential",
"default": false
}
},
"type": "object",
@@ -8357,7 +8764,8 @@
"REFUND_REQUEST",
"REFUND_PROCESSED",
"AGENT_APPROVED",
"AGENT_REJECTED"
"AGENT_REJECTED",
"WAITLIST_LAUNCH"
],
"title": "NotificationType"
},
@@ -9905,6 +10313,57 @@
"required": ["submissions", "pagination"],
"title": "StoreSubmissionsResponse"
},
"StoreWaitlistEntry": {
"properties": {
"waitlistId": { "type": "string", "title": "Waitlistid" },
"slug": { "type": "string", "title": "Slug" },
"name": { "type": "string", "title": "Name" },
"subHeading": { "type": "string", "title": "Subheading" },
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
},
"imageUrls": {
"items": { "type": "string" },
"type": "array",
"title": "Imageurls"
},
"description": { "type": "string", "title": "Description" },
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories"
}
},
"type": "object",
"required": [
"waitlistId",
"slug",
"name",
"subHeading",
"imageUrls",
"description",
"categories"
],
"title": "StoreWaitlistEntry",
"description": "Public waitlist entry - no PII fields exposed."
},
"StoreWaitlistsAllResponse": {
"properties": {
"listings": {
"items": { "$ref": "#/components/schemas/StoreWaitlistEntry" },
"type": "array",
"title": "Listings"
}
},
"type": "object",
"required": ["listings"],
"title": "StoreWaitlistsAllResponse"
},
"SubmissionStatus": {
"type": "string",
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
@@ -11649,6 +12108,201 @@
"required": ["loc", "msg", "type"],
"title": "ValidationError"
},
"WaitlistAdminListResponse": {
"properties": {
"waitlists": {
"items": { "$ref": "#/components/schemas/WaitlistAdminResponse" },
"type": "array",
"title": "Waitlists"
},
"totalCount": { "type": "integer", "title": "Totalcount" }
},
"type": "object",
"required": ["waitlists", "totalCount"],
"title": "WaitlistAdminListResponse",
"description": "Response model for listing all waitlists (admin view)."
},
"WaitlistAdminResponse": {
"properties": {
"id": { "type": "string", "title": "Id" },
"createdAt": { "type": "string", "title": "Createdat" },
"updatedAt": { "type": "string", "title": "Updatedat" },
"slug": { "type": "string", "title": "Slug" },
"name": { "type": "string", "title": "Name" },
"subHeading": { "type": "string", "title": "Subheading" },
"description": { "type": "string", "title": "Description" },
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories"
},
"imageUrls": {
"items": { "type": "string" },
"type": "array",
"title": "Imageurls"
},
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
},
"status": { "$ref": "#/components/schemas/WaitlistExternalStatus" },
"votes": { "type": "integer", "title": "Votes" },
"signupCount": { "type": "integer", "title": "Signupcount" },
"storeListingId": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Storelistingid"
},
"owningUserId": { "type": "string", "title": "Owninguserid" }
},
"type": "object",
"required": [
"id",
"createdAt",
"updatedAt",
"slug",
"name",
"subHeading",
"description",
"categories",
"imageUrls",
"status",
"votes",
"signupCount",
"owningUserId"
],
"title": "WaitlistAdminResponse",
"description": "Admin response model with full waitlist details including internal data."
},
"WaitlistCreateRequest": {
"properties": {
"name": { "type": "string", "title": "Name" },
"slug": { "type": "string", "title": "Slug" },
"subHeading": { "type": "string", "title": "Subheading" },
"description": { "type": "string", "title": "Description" },
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories",
"default": []
},
"imageUrls": {
"items": { "type": "string" },
"type": "array",
"title": "Imageurls",
"default": []
},
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
}
},
"type": "object",
"required": ["name", "slug", "subHeading", "description"],
"title": "WaitlistCreateRequest",
"description": "Request model for creating a new waitlist."
},
"WaitlistExternalStatus": {
"type": "string",
"enum": ["DONE", "NOT_STARTED", "CANCELED", "WORK_IN_PROGRESS"],
"title": "WaitlistExternalStatus"
},
"WaitlistSignup": {
"properties": {
"type": { "type": "string", "title": "Type" },
"userId": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Userid"
},
"email": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Email"
},
"username": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Username"
}
},
"type": "object",
"required": ["type"],
"title": "WaitlistSignup",
"description": "Individual signup entry for a waitlist."
},
"WaitlistSignupListResponse": {
"properties": {
"waitlistId": { "type": "string", "title": "Waitlistid" },
"signups": {
"items": { "$ref": "#/components/schemas/WaitlistSignup" },
"type": "array",
"title": "Signups"
},
"totalCount": { "type": "integer", "title": "Totalcount" }
},
"type": "object",
"required": ["waitlistId", "signups", "totalCount"],
"title": "WaitlistSignupListResponse",
"description": "Response model for listing waitlist signups."
},
"WaitlistUpdateRequest": {
"properties": {
"name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Name"
},
"slug": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Slug"
},
"subHeading": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Subheading"
},
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"categories": {
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{ "type": "null" }
],
"title": "Categories"
},
"imageUrls": {
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{ "type": "null" }
],
"title": "Imageurls"
},
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
},
"status": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Status"
},
"storeListingId": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Storelistingid"
}
},
"type": "object",
"title": "WaitlistUpdateRequest",
"description": "Request model for updating a waitlist."
},
"Webhook": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -11699,6 +12353,7 @@
"in": "header",
"name": "X-Postmark-Webhook-Token"
},
"HTTPBearer": { "type": "http", "scheme": "bearer" },
"HTTPBearerJWT": {
"type": "http",
"scheme": "bearer",

View File

@@ -20,7 +20,6 @@ export function Button(props: ButtonProps) {
rightIcon,
children,
as = "button",
asChild: _asChild, // Destructure to prevent passing to DOM
...restProps
} = props;

View File

@@ -3,7 +3,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
interface Props {
interface MarketplaceBannersProps {
hasUpdate?: boolean;
latestVersion?: number;
hasUnpublishedChanges?: boolean;
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
isUpdating,
onUpdate,
onPublish,
}: Props) {
}: MarketplaceBannersProps) {
const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) {
return (

View File

@@ -49,12 +49,7 @@ export function DrawerWrap({
>
{title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : (
<span className="sr-only">
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
<Drawer.Title>{title}</Drawer.Title>
</span>
)}
) : null}
{!isForceOpen ? (
title ? (

View File

@@ -593,7 +593,6 @@ export type CredentialsMetaResponse = {
scopes?: Array<string>;
username?: string;
host?: string;
is_system?: boolean;
};
/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */

View File

@@ -1,4 +1,5 @@
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { createContext, useCallback, useEffect, useState } from "react";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import {
APIKeyCredentials,
CredentialsDeleteNeedConfirmationResponse,
@@ -9,9 +10,8 @@ import {
UserPasswordCredentials,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { createContext, useCallback, useEffect, useState } from "react";
type APIKeyCredentialsCreatable = Omit<
APIKeyCredentials,
@@ -72,8 +72,6 @@ export default function CredentialsProvider({
const api = useBackendAPI();
const onFailToast = useToastOnFail();
console.log("providers", providers);
const addCredentials = useCallback(
(
provider: CredentialsProviderName,
@@ -220,7 +218,17 @@ export default function CredentialsProvider({
[api, onFailToast],
);
const loadCredentials = useCallback(() => {
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("load provider names"));
}, [api, onFailToast]);
useEffect(() => {
if (!isLoggedIn || providerNames.length === 0) {
if (isLoggedIn == false) setProviders({});
return;
@@ -280,20 +288,6 @@ export default function CredentialsProvider({
onFailToast,
]);
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("Load provider names"));
}, [api, onFailToast]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
return (
<CredentialsProvidersContext.Provider value={providers}>
{children}