diff --git a/apps/pi/pi/app/api/v1/endpoints/chat.py b/apps/pi/pi/app/api/v1/endpoints/chat.py index 726c0e56a9..e2af7573c9 100644 --- a/apps/pi/pi/app/api/v1/endpoints/chat.py +++ b/apps/pi/pi/app/api/v1/endpoints/chat.py @@ -6,6 +6,7 @@ from typing import AsyncGenerator from typing import Coroutine from typing import Optional from typing import cast +from urllib.parse import urlparse from uuid import UUID from uuid import uuid4 @@ -20,6 +21,7 @@ from sqlalchemy import select from sqlmodel.ext.asyncio.session import AsyncSession from pi import logger +from pi import settings from pi.app.api.v1.dependencies import cookie_schema from pi.app.api.v1.dependencies import is_valid_session from pi.app.api.v1.dependencies import validate_plane_token @@ -39,6 +41,7 @@ from pi.app.schemas.chat import ActionBatchExecutionRequest from pi.app.schemas.chat import ArtifactData from pi.app.schemas.chat import ChatFeedback from pi.app.schemas.chat import ChatInitializationRequest +from pi.app.schemas.chat import ChatInitResponse from pi.app.schemas.chat import ChatRequest from pi.app.schemas.chat import ChatSearchResponse from pi.app.schemas.chat import ChatSuggestionTemplate @@ -57,6 +60,7 @@ from pi.app.utils.background_tasks import schedule_chat_search_upsert from pi.app.utils.exceptions import SQLGenerationError from pi.core.db.plane_pi.lifecycle import get_async_session from pi.core.db.plane_pi.lifecycle import get_streaming_db_session +from pi.services.actions.oauth_service import PlaneOAuthService from pi.services.chat.chat import PlaneChatBot from pi.services.chat.helpers.batch_execution_helpers import get_original_user_query from pi.services.chat.helpers.batch_execution_helpers import get_planned_actions_for_execution @@ -97,6 +101,71 @@ BATCH_EXECUTION_ERRORS = { } +@router.get("/start/", response_model=ChatInitResponse) +async def chat_start( + workspace_id: UUID = Query(..., description="Workspace ID to check authorization for"), + db: AsyncSession = Depends(get_async_session), + session: str = Depends(cookie_schema), +): + """ + Start/Bootstrap Pi chat - first API call when starting a chat session. + Checks OAuth authorization status and returns chat templates. + + Usage: GET /api/v1/chat/start/?workspace_id= + + If authorized: returns is_authorized=true with full templates list + If not authorized: returns is_authorized=false with empty templates list + """ + try: + # Validate session + auth = await is_valid_session(session) + if not auth.user: + return JSONResponse(status_code=401, content={"detail": "Invalid User"}) + user_id = auth.user.id + except Exception as e: + log.error(f"Error validating session: {e!s}") + return JSONResponse(status_code=401, content={"detail": "Invalid Session"}) + + try: + oauth_service = PlaneOAuthService() + + # Check if user has valid OAuth token for workspace + access_token = await oauth_service.get_valid_token(db=db, user_id=user_id, workspace_id=workspace_id) + + if access_token: + # User is authorized - get templates + templates = tiles_factory() + return ChatInitResponse(is_authorized=True, templates=templates, oauth_url=None) + else: + # User is not authorized - return empty templates with OAuth URL + from pi.services.actions.oauth_url_encoder import OAuthUrlEncoder + + redirect = urlparse(settings.plane_api.OAUTH_REDIRECT_URI) + base_url = f"{redirect.scheme}://{redirect.netloc}" + + # Include BASE_PATH if configured + base_path = settings.plane_api.BASE_PATH or "" + if base_path and not base_path.startswith("/"): + base_path = f"/{base_path}" + base_path = base_path.rstrip("/") + + # Build OAuth parameters (only user_id and workspace_id) + oauth_params = { + "user_id": str(user_id), + "workspace_id": str(workspace_id), + } + + # Generate clean, encrypted OAuth URL + oauth_encoder = OAuthUrlEncoder() + oauth_url = oauth_encoder.generate_clean_oauth_url(f"{base_url}{base_path}", oauth_params) + + return ChatInitResponse(is_authorized=False, templates=[], oauth_url=oauth_url) + + except Exception as e: + log.error(f"Error in chat start: {e!s}") + return JSONResponse(status_code=500, content={"detail": "Failed to initialize chat"}) + + @router.post("/slack/answer/") async def get_answer_for_slack(data: ChatRequest, request: Request, db: AsyncSession = Depends(get_async_session)): try: diff --git a/apps/pi/pi/app/api/v1/endpoints/oauth.py b/apps/pi/pi/app/api/v1/endpoints/oauth.py index 91ff40aa67..1ffe53ddb0 100644 --- a/apps/pi/pi/app/api/v1/endpoints/oauth.py +++ b/apps/pi/pi/app/api/v1/endpoints/oauth.py @@ -4,6 +4,7 @@ Handles authorization flow, callback processing, and token management """ from datetime import datetime +from typing import Optional from uuid import UUID from fastapi import APIRouter @@ -35,10 +36,11 @@ async def browser_initiate_oauth( db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema), ): + """Browser-based OAuth initiation with session cookie authentication.""" auth = await is_valid_session(session) if not auth.user: return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - user_id = auth.user.id + oauth_service = PlaneOAuthService() # Clean up expired states periodically @@ -48,19 +50,16 @@ async def browser_initiate_oauth( log.warning(f"Failed to cleanup expired states: {e}") # Get workspace slug if workspace_id is provided - workspace_slug = None - if workspace_id: - workspace_slug = await get_workspace_slug(str(workspace_id)) + workspace_slug = await get_workspace_slug(str(workspace_id)) if workspace_id else None - # same logic as POST /initiate/ + # Generate authorization URL and save state auth_url, state = oauth_service.generate_authorization_url( - user_id=user_id, + user_id=auth.user.id, workspace_id=workspace_id, workspace_slug=workspace_slug, ) - await oauth_service.save_oauth_state(db, state, user_id, workspace_id, workspace_slug) + await oauth_service.save_oauth_state(db, state, auth.user.id, workspace_id, workspace_slug) - # just redirect the browser return RedirectResponse(url=auth_url, status_code=302) @@ -69,10 +68,11 @@ async def public_initiate_oauth( user_id: UUID = Query(..., description="User ID requesting authorization"), workspace_id: UUID = Query(..., description="Workspace ID to authorize"), force_reset: bool = Query(False, description="Force reset of existing OAuth states"), + sidebar_open_url: Optional[str] = Query(None, description="The URL where the sidebar was opened from"), db: AsyncSession = Depends(get_async_session), ): """ - Public OAuth initiation endpoint that doesn't require session authentication. + Public OAuth initiation endpoint without session authentication. Used when session cookies can't be shared across domains. """ oauth_service = PlaneOAuthService() @@ -83,7 +83,7 @@ async def public_initiate_oauth( except Exception as e: log.warning(f"Failed to cleanup expired states: {e}") - # If force_reset is requested, clear existing states for this user/workspace + # Force reset if requested if force_reset: try: reset_count = await oauth_service.reset_user_oauth_states(db, user_id, workspace_id) @@ -91,10 +91,8 @@ async def public_initiate_oauth( except Exception as e: log.warning(f"Failed to reset OAuth states: {e}") - # Get workspace slug for the workspace - workspace_slug = None - if workspace_id: - workspace_slug = await get_workspace_slug(str(workspace_id)) + # Get workspace slug + workspace_slug = await get_workspace_slug(str(workspace_id)) if workspace_id else None # Generate authorization URL with state auth_url, state = oauth_service.generate_authorization_url( @@ -103,19 +101,25 @@ async def public_initiate_oauth( workspace_slug=workspace_slug, ) - # Save state for verification - await oauth_service.save_oauth_state(db, state, user_id, workspace_id, workspace_slug) + # Save OAuth state with sidebar context + await oauth_service.save_oauth_state( + db, + state, + user_id, + workspace_id, + workspace_slug, + pi_sidebar_open=sidebar_open_url is not None, + sidebar_open_url=sidebar_open_url, + ) - log.debug(f"Generated new OAuth state for user {user_id}") - - # Redirect to Plane for authorization + log.debug(f"Generated OAuth state for user {user_id}") return RedirectResponse(url=auth_url, status_code=302) @router.get("/callback/") async def oauth_callback( code: str = Query(..., description="Authorization code from Plane"), - state: str | None = Query(None, description="State parameter for verification - optional for marketplace install"), + state: str | None = Query(None, description="State parameter for verification"), app_installation_id: str = Query(None, description="App installation ID from Plane"), db: AsyncSession = Depends(get_async_session), ): @@ -125,41 +129,33 @@ async def oauth_callback( """ try: oauth_service = PlaneOAuthService() + log.debug(f"OAuth callback - code: {code[:10]}..., state: {state}, app_installation_id: {app_installation_id}") - log.debug(f"OAuth callback received - code: {code[:10]}..., state: {state}, app_installation_id: {app_installation_id}") - + # Verify state parameter oauth_state = None if state: - # Flow initiated via /oauth/initiate/ or /oauth/init/ oauth_state = await oauth_service.verify_state(db, state) if not oauth_state: log.error(f"Invalid or expired OAuth state: {state}") - # Clean up expired states to help with future requests cleanup_count = await oauth_service.cleanup_expired_states(db) log.info(f"Cleaned up {cleanup_count} expired states") return JSONResponse(status_code=400, content={"detail": "Invalid or expired authorization state. Please try initiating OAuth again."}) - else: - log.debug(f"OAuth state verified successfully: {state}") + log.debug(f"OAuth state verified: {state}") else: log.info("Processing OAuth callback without state parameter") # Exchange code for tokens token_data = await oauth_service.exchange_code_for_tokens(code=code) - # Get app installation details to fetch workspace info + # Extract workspace and user info from state workspace_id = oauth_state.workspace_id if oauth_state else None workspace_slug = oauth_state.workspace_slug if oauth_state and oauth_state.workspace_slug else "unknown" + user_id_for_token = oauth_state.user_id if oauth_state else None - # Ensure we have a valid workspace_id + # Validate required fields if not workspace_id: - log.error("No workspace_id available from state or installation details") + log.error("No workspace_id available from state") raise Exception("Unable to determine workspace for token storage") - - # Determine user ID to associate with token - user_id_for_token = None - if oauth_state: - user_id_for_token = oauth_state.user_id - if not user_id_for_token: log.error("Unable to determine user_id for token storage") raise Exception("user_id required for token storage") @@ -175,85 +171,77 @@ async def oauth_callback( app_bot_user_id=None, ) - # Mark state as used only after successful completion + # Mark state as used if oauth_state: await oauth_service.mark_state_as_used(db, oauth_state) - log.info( - "OAuth completed successfully for user %s, workspace %s", - str(user_id_for_token), - workspace_slug, - ) + log.info(f"OAuth completed for user {user_id_for_token}, workspace {workspace_slug}") # Determine redirect URL based on stored context - if oauth_state and oauth_state.chat_id: - # Include message_token in redirect URL for frontend to use with stream-answer - message_token_param = "" - if oauth_state.message_token: - message_token_param = f"&message_token={oauth_state.message_token}" - - # Update the MessageFlowStep to mark OAuth as complete - try: - from sqlalchemy import text - - # Update the oauth_completed column to mark OAuth as complete - update_stmt = text(""" - UPDATE message_flow_steps - SET oauth_completed = true, oauth_completed_at = :timestamp - WHERE message_id = :message_id AND tool_name = 'QUEUE' - """) - await db.execute(update_stmt, {"message_id": str(oauth_state.message_token), "timestamp": datetime.utcnow()}) - await db.commit() - log.info(f"Updated MessageFlowStep {oauth_state.message_token} to mark OAuth as complete") - except Exception as e: - log.warning(f"Failed to update MessageFlowStep OAuth status: {e}") - # Don't fail the OAuth flow if this update fails - - # Sidebar open redirect - if getattr(oauth_state, "pi_sidebar_open", False) and oauth_state.sidebar_open_url: - # Build sidebar redirect URL - sidebar_url = oauth_state.sidebar_open_url - chat_id = oauth_state.chat_id - redirect_url = ( - f"{settings.plane_api.FRONTEND_URL}/{sidebar_url}/?chat_id={chat_id}{message_token_param}&pi_sidebar_open=true&oauth_success=true" # noqa: E501 - ) - # Project chat redirect - elif getattr(oauth_state, "is_project_chat", False): - redirect_url = f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/projects/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}" # noqa: E501 - # Default chat redirect - else: - redirect_url = ( - f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}" - ) - else: - # Fallback to default success page - redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_success=true&workspace={workspace_slug}" + redirect_url = await _build_redirect_url(oauth_state, workspace_slug, db) return RedirectResponse(url=redirect_url, status_code=302) except Exception as e: log.error(f"Error processing OAuth callback: {e!s}") - # Redirect to frontend with error - frontend_url = settings.plane_api.FRONTEND_URL - redirect_url = f"{frontend_url}?oauth_error=true&message=authorization_failed" + redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_error=true&message=authorization_failed" return RedirectResponse(url=redirect_url, status_code=302) +async def _build_redirect_url(oauth_state, workspace_slug: str, db: AsyncSession) -> str: + """Build the appropriate redirect URL based on OAuth state context.""" + if not oauth_state: + return f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/?oauth_success=true" + + # Handle message token parameter + message_token_param = "" + if oauth_state.message_token: + message_token_param = f"&message_token={oauth_state.message_token}" + + # Update MessageFlowStep to mark OAuth as complete + try: + from sqlalchemy import text + + update_stmt = text(""" + UPDATE message_flow_steps + SET oauth_completed = true, oauth_completed_at = :timestamp + WHERE message_id = :message_id AND tool_name = 'QUEUE' + """) + await db.execute(update_stmt, {"message_id": str(oauth_state.message_token), "timestamp": datetime.utcnow()}) + await db.commit() + log.info(f"Updated MessageFlowStep {oauth_state.message_token} to mark OAuth as complete") + except Exception as e: + log.warning(f"Failed to update MessageFlowStep OAuth status: {e}") + + # Sidebar open redirect (highest priority) + if getattr(oauth_state, "pi_sidebar_open", False) and oauth_state.sidebar_open_url: + return oauth_state.sidebar_open_url + + # Chat-specific redirects + if oauth_state.chat_id: + # Project chat redirect + if getattr(oauth_state, "is_project_chat", False): + return ( + f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/projects/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}" + ) + # Default chat redirect + return f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/{oauth_state.chat_id}/?oauth_success=true{message_token_param}" + + # Fallback to default success page + return f"{settings.plane_api.FRONTEND_URL}/{workspace_slug}/pi-chat/?oauth_success=true" + + @router.post("/status/", response_model=OAuthStatusResponse) async def check_oauth_status( data: OAuthStatusRequest, db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema), ): - """ - Check OAuth authorization status for a specific workspace. - Returns whether user has valid authorization and token details. - """ + """Check OAuth authorization status for a specific workspace.""" try: auth = await is_valid_session(session) if not auth.user: return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - user_id = auth.user.id except Exception as e: log.error(f"Error validating session: {e!s}") return JSONResponse(status_code=401, content={"detail": "Invalid Session"}) @@ -262,7 +250,7 @@ async def check_oauth_status( oauth_service = PlaneOAuthService() # Check if user has valid token for workspace - access_token = await oauth_service.get_valid_token(db=db, user_id=user_id, workspace_id=data.workspace_id) + access_token = await oauth_service.get_valid_token(db=db, user_id=auth.user.id, workspace_id=data.workspace_id) if access_token: # Get token details for expiry info @@ -272,7 +260,9 @@ async def check_oauth_status( result = await db.execute( select(PlaneOAuthToken).where( - PlaneOAuthToken.user_id == user_id, PlaneOAuthToken.workspace_id == data.workspace_id, PlaneOAuthToken.is_active is True + PlaneOAuthToken.user_id == auth.user.id, + PlaneOAuthToken.workspace_id == data.workspace_id, + PlaneOAuthToken.is_active is True, ) ) oauth_token = result.scalar_one_or_none() @@ -282,8 +272,8 @@ async def check_oauth_status( workspace_slug=oauth_token.workspace_slug if oauth_token else None, expires_at=oauth_token.expires_at.isoformat() if oauth_token else None, ) - else: - return OAuthStatusResponse(is_authorized=False, workspace_slug=None, expires_at=None) + + return OAuthStatusResponse(is_authorized=False, workspace_slug=None, expires_at=None) except Exception as e: log.error(f"Error checking OAuth status: {e!s}") @@ -296,30 +286,24 @@ async def revoke_oauth_authorization( db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema), ): - """ - Revoke OAuth authorization for a specific workspace. - Deactivates stored tokens and prevents further API access. - """ + """Revoke OAuth authorization for a specific workspace.""" try: auth = await is_valid_session(session) if not auth.user: return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - user_id = auth.user.id except Exception as e: log.error(f"Error validating session: {e!s}") return JSONResponse(status_code=401, content={"detail": "Invalid Session"}) try: oauth_service = PlaneOAuthService() - - # Revoke token - success = await oauth_service.revoke_token(db=db, user_id=user_id, workspace_id=data.workspace_id) + success = await oauth_service.revoke_token(db=db, user_id=auth.user.id, workspace_id=data.workspace_id) if success: - log.info(f"OAuth authorization revoked for user {user_id}, workspace {data.workspace_id}") + log.info(f"OAuth authorization revoked for user {auth.user.id}, workspace {data.workspace_id}") return OAuthRevokeResponse(success=True, message="Authorization revoked successfully") - else: - return OAuthRevokeResponse(success=False, message="No active authorization found for this workspace") + + return OAuthRevokeResponse(success=False, message="No active authorization found for this workspace") except Exception as e: log.error(f"Error revoking OAuth authorization: {e!s}") @@ -331,15 +315,11 @@ async def list_authorized_workspaces( db: AsyncSession = Depends(get_async_session), session: str = Depends(cookie_schema), ): - """ - List all workspaces the user has authorized access to. - Returns workspace details and token status. - """ + """List all workspaces the user has authorized access to.""" try: auth = await is_valid_session(session) if not auth.user: return JSONResponse(status_code=401, content={"detail": "Invalid User"}) - user_id = auth.user.id except Exception as e: log.error(f"Error validating session: {e!s}") return JSONResponse(status_code=401, content={"detail": "Invalid Session"}) @@ -350,18 +330,19 @@ async def list_authorized_workspaces( from pi.app.models.oauth import PlaneOAuthToken # Get all active tokens for user - result = await db.execute(select(PlaneOAuthToken).where(PlaneOAuthToken.user_id == user_id, PlaneOAuthToken.is_active is True)) + result = await db.execute(select(PlaneOAuthToken).where(PlaneOAuthToken.user_id == auth.user.id, PlaneOAuthToken.is_active is True)) tokens = result.scalars().all() - workspaces = [] - for token in tokens: - workspaces.append({ + workspaces = [ + { "workspace_id": str(token.workspace_id), "workspace_slug": token.workspace_slug, "expires_at": token.expires_at.isoformat(), "needs_refresh": token.needs_refresh(), "is_expired": token.is_expired(), - }) + } + for token in tokens + ] return JSONResponse(content={"workspaces": workspaces}) @@ -373,20 +354,15 @@ async def list_authorized_workspaces( @router.post("/reset-states/") async def reset_oauth_states( user_id: UUID = Query(..., description="User ID to reset states for"), - workspace_id: UUID = Query(None, description="Workspace ID to reset (optional - if not provided, resets all user states)"), + workspace_id: UUID = Query(None, description="Workspace ID to reset (optional)"), db: AsyncSession = Depends(get_async_session), ): - """ - Reset OAuth states for a user to allow fresh authorization attempts. - This endpoint can be used when users are stuck with invalid/expired state errors. - """ + """Reset OAuth states for a user to allow fresh authorization attempts.""" try: oauth_service = PlaneOAuthService() # Reset states for the user reset_count = await oauth_service.reset_user_oauth_states(db, user_id, workspace_id) - - # Also cleanup expired states cleanup_count = await oauth_service.cleanup_expired_states(db) message = f"Reset {reset_count} OAuth states for user {user_id}" @@ -395,7 +371,6 @@ async def reset_oauth_states( message += f". Cleaned up {cleanup_count} expired states." log.info(message) - return JSONResponse(content={"success": True, "message": message, "reset_count": reset_count, "cleanup_count": cleanup_count}) except Exception as e: @@ -406,16 +381,17 @@ async def reset_oauth_states( @router.get("/authorize/{encoded_params}", tags=["oauth"]) async def clean_oauth_init( encoded_params: str, + sidebar_open_url: Optional[str] = Query(None, description="Sidebar open URL from frontend"), + pi_sidebar_open: Optional[str] = Query(None, description="Pi sidebar open flag from frontend"), db: AsyncSession = Depends(get_async_session), ): """ - Clean OAuth initiation endpoint with encrypted parameters. - This replaces the ugly URL with a clean, encrypted one. + Clean OAuth initialization endpoint with encrypted parameters. """ try: from pi.services.actions.oauth_url_encoder import OAuthUrlEncoder - # Decode the parameters + # Decode the parameters from encrypted token oauth_encoder = OAuthUrlEncoder() params = oauth_encoder.decode_oauth_params(encoded_params) @@ -426,8 +402,20 @@ async def clean_oauth_init( message_token = params.get("message_token") is_project_chat = params.get("is_project_chat", "false").lower() == "true" project_id = params.get("project_id") - pi_sidebar_open = params.get("pi_sidebar_open", "false").lower() == "true" - sidebar_open_url = params.get("sidebar_open_url") + + # Use query parameters if provided, otherwise fallback to encrypted params + final_sidebar_open_url = sidebar_open_url or params.get("sidebar_open_url") + final_pi_sidebar_open = ( + pi_sidebar_open.strip().lower() == "true" if pi_sidebar_open else params.get("pi_sidebar_open", "false").lower() == "true" + ) + + # Set pi_sidebar_open=True when sidebar_open_url is provided + if final_sidebar_open_url and not final_pi_sidebar_open: + final_pi_sidebar_open = True + + # Prepend FRONTEND_URL if sidebar_open_url is a relative path + if final_sidebar_open_url and not final_sidebar_open_url.startswith(("http://", "https://")): + final_sidebar_open_url = f"{settings.plane_api.FRONTEND_URL}{final_sidebar_open_url}" oauth_service = PlaneOAuthService() @@ -438,9 +426,11 @@ async def clean_oauth_init( log.warning(f"Failed to cleanup expired states: {e}") # Get workspace slug for the workspace - workspace_slug = None - if workspace_id: - workspace_slug = await get_workspace_slug(str(workspace_id)) + workspace_slug = await get_workspace_slug(str(workspace_id)) + if not workspace_slug: + log.error(f"Workspace not found for id {workspace_id}") + redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_error=true&message=workspace_not_found" + return RedirectResponse(url=redirect_url, status_code=302) # Generate authorization URL with state auth_url, state = oauth_service.generate_authorization_url( @@ -460,18 +450,16 @@ async def clean_oauth_init( message_token=message_token, is_project_chat=is_project_chat, project_id=project_id, - pi_sidebar_open=pi_sidebar_open, - sidebar_open_url=sidebar_open_url, + pi_sidebar_open=final_pi_sidebar_open, + sidebar_open_url=final_sidebar_open_url, ) - log.debug(f"Generated new OAuth state for user {user_id}") + log.debug(f"Generated OAuth state for user {user_id}, workspace {workspace_slug}") # Redirect to Plane for authorization return RedirectResponse(url=auth_url, status_code=302) except Exception as e: - log.error(f"Error processing clean OAuth init: {e}") - # Redirect to frontend with error - frontend_url = settings.plane_api.FRONTEND_URL - redirect_url = f"{frontend_url}?oauth_error=true&message=invalid_parameters" + log.error(f"Error processing OAuth init: {e}") + redirect_url = f"{settings.plane_api.FRONTEND_URL}?oauth_error=true&message=invalid_parameters" return RedirectResponse(url=redirect_url, status_code=302) diff --git a/apps/pi/pi/app/schemas/chat.py b/apps/pi/pi/app/schemas/chat.py index e21eab2709..5b1ef69385 100644 --- a/apps/pi/pi/app/schemas/chat.py +++ b/apps/pi/pi/app/schemas/chat.py @@ -228,3 +228,17 @@ class GetThreadsPaginatedResponse(PaginationResponse): """Paginated response for user chat threads.""" results: list[dict[str, Any]] + + +class ChatInitRequest(BaseModel): + """Request to initialize chat - checks OAuth and gets templates""" + + workspace_id: UUID4 = Field(description="Workspace ID to check") + + +class ChatInitResponse(BaseModel): + """Response with OAuth status and chat templates for initialization""" + + is_authorized: bool = Field(description="Whether user has valid authorization for workspace") + templates: List[Any] = Field(default_factory=list, description="Chat suggestion templates") + oauth_url: Optional[str] = Field(None, description="OAuth authorization URL if not authorized") diff --git a/apps/web/app/assets/auth/pi-chat-dark.webp b/apps/web/app/assets/auth/pi-chat-dark.webp new file mode 100644 index 0000000000..0cf59ae779 Binary files /dev/null and b/apps/web/app/assets/auth/pi-chat-dark.webp differ diff --git a/apps/web/app/assets/auth/pi-chat-light.webp b/apps/web/app/assets/auth/pi-chat-light.webp new file mode 100644 index 0000000000..0d05011313 Binary files /dev/null and b/apps/web/app/assets/auth/pi-chat-light.webp differ diff --git a/apps/web/ee/components/home/header.tsx b/apps/web/ee/components/home/header.tsx index 9274b9d997..46fc5d2328 100644 --- a/apps/web/ee/components/home/header.tsx +++ b/apps/web/ee/components/home/header.tsx @@ -11,10 +11,11 @@ import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature"; import { BetaBadge } from "../common/beta"; import { WithFeatureFlagHOC } from "../feature-flags"; import { InputBox } from "../pi-chat/input"; +import { UnauthorizedView } from "../pi-chat/unauthorized"; export const HomePageHeader = observer(() => { const { workspaceSlug } = useParams(); - const { activeChatId, initPiChat } = usePiChat(); + const { activeChatId, isWorkspaceAuthorized, initPiChat } = usePiChat(); const { isWorkspaceFeatureEnabled } = useWorkspaceFeatures(); if (!isWorkspaceFeatureEnabled(EWorkspaceFeatures.IS_PI_ENABLED)) return <>; @@ -36,13 +37,20 @@ export const HomePageHeader = observer(() => { - + {isWorkspaceAuthorized ? ( + + ) : ( + + )} ); diff --git a/apps/web/ee/components/pi-chat/conversation/ai-message.tsx b/apps/web/ee/components/pi-chat/conversation/ai-message.tsx index a0aaa6e19d..fe9633696d 100644 --- a/apps/web/ee/components/pi-chat/conversation/ai-message.tsx +++ b/apps/web/ee/components/pi-chat/conversation/ai-message.tsx @@ -43,14 +43,11 @@ export const AiMessage = observer((props: TProps) => { remarkPlugins={[remarkGfm]} className="pi-chat-root [&>*:first-child]:mt-0 animate-fade-in" components={{ - a: ({ children, href }) => - href?.startsWith(`${PI_URL}/api/v1/oauth/authorize/`) && !isLatest ? ( // NOTE: Prev auth links shouldn't be accessible - {children} - ) : ( - - {children} - - ), + a: ({ children, href }) => ( + + {children} + + ), table: ({ children }) => (
{children}
diff --git a/apps/web/ee/components/pi-chat/conversation/feedback.tsx b/apps/web/ee/components/pi-chat/conversation/feedback.tsx index ae925a99d9..a95ee4de5e 100644 --- a/apps/web/ee/components/pi-chat/conversation/feedback.tsx +++ b/apps/web/ee/components/pi-chat/conversation/feedback.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { observer } from "mobx-react"; import { Copy, FilePlus2, ThumbsDown, ThumbsUp, Repeat2 } from "lucide-react"; import { setToast, TOAST_TYPE } from "@plane/propel/toast"; import { cn, Tooltip } from "@plane/ui"; @@ -18,13 +19,13 @@ export type TProps = { handleConvertToPage?: () => void; }; -export const Feedback = (props: TProps) => { +export const Feedback = observer((props: TProps) => { // props const { answer, activeChatId, id, workspaceId, feedback, queryId, isLatest, handleConvertToPage } = props; // states const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false); // store - const { sendFeedback, regenerateAnswer } = usePiChat(); + const { isWorkspaceAuthorized, sendFeedback, regenerateAnswer } = usePiChat(); // handlers const handleCopyLink = () => { copyTextToClipboard(answer).then(() => { @@ -119,12 +120,21 @@ export const Feedback = (props: TProps) => { {/* Convert to page */}
- -
); -}; +}); diff --git a/apps/web/ee/components/pi-chat/conversation/new-converstaion.tsx b/apps/web/ee/components/pi-chat/conversation/new-converstaion.tsx index 72def83e5f..1fa7bceacc 100644 --- a/apps/web/ee/components/pi-chat/conversation/new-converstaion.tsx +++ b/apps/web/ee/components/pi-chat/conversation/new-converstaion.tsx @@ -7,7 +7,9 @@ import { Loader } from "@plane/ui"; import { cn } from "@plane/utils"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat"; +import type { TTemplate } from "@/plane-web/types"; import SystemPrompts from "../system-prompts"; +import { UnauthorizedView } from "../unauthorized"; type TProps = { currentUser: IUser | undefined; @@ -19,19 +21,24 @@ export const NewConversation = observer((props: TProps) => { const { currentUser, isFullScreen, shouldRedirect = true, isProjectLevel = false } = props; // store hooks const { workspaceSlug } = useParams(); - const { getTemplates } = usePiChat(); + const { getInstance } = usePiChat(); const { getWorkspaceBySlug } = useWorkspace(); const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id; - const { data: templates, isLoading } = useSWR("PI_TEMPLATES", () => getTemplates(workspaceId), { - revalidateOnFocus: false, - revalidateIfStale: false, - errorRetryCount: 0, - }); + // SWR + const { data: instance, isLoading } = useSWR( + workspaceId ? `PI_STARTER_${workspaceId}` : null, + workspaceId ? () => getInstance(workspaceId) : null, + { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + } + ); // state const [isInitializing, setIsInitializing] = useState(null); if (!currentUser) return null; - + if (!isLoading && !instance?.is_authorized) return ; return (
{
) : ( -
- {templates?.map((prompt, index) => ( - setIsInitializing(value)} - /> - ))} +
+ {instance?.is_authorized && + instance?.templates?.map((prompt: TTemplate, index: number) => ( + setIsInitializing(value)} + /> + ))}
)}
diff --git a/apps/web/ee/components/pi-chat/detail.tsx b/apps/web/ee/components/pi-chat/detail.tsx index 4fe5d92f81..42c7ee5e4b 100644 --- a/apps/web/ee/components/pi-chat/detail.tsx +++ b/apps/web/ee/components/pi-chat/detail.tsx @@ -11,6 +11,7 @@ import { Loading } from "./conversation/loading"; import { Messages } from "./conversation/messages"; import { scrollIntoViewHelper } from "./helper"; import { InputBox } from "./input"; +import { UnauthorizedView } from "./unauthorized"; type TProps = { isFullScreen?: boolean; @@ -23,13 +24,13 @@ export const PiChatDetail = observer((props: TProps) => { // router const pathName = usePathname(); // store hooks - const { isAuthorized, isLoading, activeChatId } = usePiChat(); + const { isAuthorized: isChatAuthorized, isWorkspaceAuthorized, isLoading, activeChatId } = usePiChat(); const { data: currentUser } = useUser(); // derived values const isFullScreen = pathName.includes("pi-chat") || isFullScreenProp; return ( <> - {isAuthorized ? ( + {isChatAuthorized && isWorkspaceAuthorized ? (
{ />
- ) : ( + ) : isWorkspaceAuthorized ? ( + ) : ( + )} ); diff --git a/apps/web/ee/components/pi-chat/unauthorized.tsx b/apps/web/ee/components/pi-chat/unauthorized.tsx new file mode 100644 index 0000000000..6f19c7baaf --- /dev/null +++ b/apps/web/ee/components/pi-chat/unauthorized.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +import { getButtonStyling } from "@plane/propel/button"; +import { cn, resolveGeneralTheme } from "@plane/utils"; +import darkState from "@/app/assets/auth/pi-chat-dark.webp?url"; +import lightState from "@/app/assets/auth/pi-chat-light.webp?url"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { usePiChat } from "@/plane-web/hooks/store/use-pi-chat"; + +export const UnauthorizedView = (props: { className?: string; imgClassName?: string }) => { + const { className, imgClassName } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + // store hooks + const { isPiChatDrawerOpen, getInstance } = usePiChat(); + const { resolvedTheme } = useTheme(); + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id; + // SWR + const { data: instance } = useSWR( + workspaceId ? `PI_STARTER_${workspaceId}` : null, + workspaceId ? () => getInstance(workspaceId) : null, + { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + } + ); + + return ( +
+
+
+ Unauthorized +
+ +
+
+
+ Plane AI can now take actions for you. +
+
+ Use Build mode to create work items, cycles and more. Activate now to start Plane AI actions. +
+
+ {instance && !instance?.is_authorized && ( + + Activate Build mode + + )} +
+
+
+ ); +}; diff --git a/apps/web/ee/layouts/workspace-wrapper.tsx b/apps/web/ee/layouts/workspace-wrapper.tsx index ee2b9d6292..4721b80eb1 100644 --- a/apps/web/ee/layouts/workspace-wrapper.tsx +++ b/apps/web/ee/layouts/workspace-wrapper.tsx @@ -31,6 +31,7 @@ import { import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects"; import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature"; import { useInitiatives } from "../hooks/store/use-initiatives"; +import { usePiChat } from "../hooks/store/use-pi-chat"; export const WorkspaceAuthWrapper: FC = observer((props) => { // props const { children } = props; @@ -55,6 +56,8 @@ export const WorkspaceAuthWrapper: FC = observer((props) const { fetchAllCustomerPropertiesAndOptions } = useCustomerProperties(); const { isCustomersFeatureEnabled, fetchCustomers } = useCustomers(); const { initiative } = useInitiatives(); + const { getWorkspaceBySlug } = useWorkspace(); + const { getInstance } = usePiChat(); // derived values const isFreeMemberCountExceeded = subscriptionDetail?.is_free_member_count_exceeded; const isWorkspaceSettingsRoute = pathname.includes(`/${workspaceSlug}/settings`); @@ -68,7 +71,7 @@ export const WorkspaceAuthWrapper: FC = observer((props) const isPageTemplatesEnabled = useFlag(workspaceSlug?.toString(), "PAGE_TEMPLATES"); const isInitiativesFeatureEnabled = initiative.isInitiativesFeatureEnabled; const isTemplatePublishEnabled = getIsTemplatePublishEnabled(workspaceSlug.toString()); - + const workspaceId = getWorkspaceBySlug(workspaceSlug as string)?.id; // fetching feature flags const { isLoading: flagsLoader, error: flagsError } = useSWR( workspaceSlug ? `WORKSPACE_FLAGS_${workspaceSlug}` : null, @@ -216,6 +219,12 @@ export const WorkspaceAuthWrapper: FC = observer((props) { revalidateIfStale: false, revalidateOnFocus: false } ); + useSWR(workspaceId ? `PI_STARTER_${workspaceId}` : null, workspaceId ? () => getInstance(workspaceId) : null, { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + // loading state const isLoading = flagsLoader && !flagsError; diff --git a/apps/web/ee/services/pi-chat.service.ts b/apps/web/ee/services/pi-chat.service.ts index 07143dbdd0..9f3127ac22 100644 --- a/apps/web/ee/services/pi-chat.service.ts +++ b/apps/web/ee/services/pi-chat.service.ts @@ -9,7 +9,7 @@ import type { TFeedback, TQuery, TSearchQuery, - TTemplate, + TInstanceResponse, TUserThreads, TAiModels, TInitPayload, @@ -23,9 +23,6 @@ import type { TPiAttachment, } from "../types"; -type TTemplateResponse = { - templates: TTemplate[]; -}; type TChatHistoryResponse = { results: { chat_id: string; @@ -123,11 +120,11 @@ export class PiChatService extends APIService { return r; } - // fetch templates - async listTemplates(workspaceId: string | undefined): Promise { - return this.get(`/api/v1/chat/get-templates/`, { + // fetch instance + async getInstance(workspaceId: string): Promise { + return this.get(`/api/v1/chat/start/`, { params: { - ...(workspaceId && { workspace_id: workspaceId }), + workspace_id: workspaceId, }, }) .then((response) => response?.data) diff --git a/apps/web/ee/store/pi-chat/pi-chat.ts b/apps/web/ee/store/pi-chat/pi-chat.ts index 3706233df1..732beb4744 100644 --- a/apps/web/ee/store/pi-chat/pi-chat.ts +++ b/apps/web/ee/store/pi-chat/pi-chat.ts @@ -16,10 +16,10 @@ import type { TFocus, TInitPayload, TQuery, - TTemplate, TUserThreads, TArtifact, TUpdatedArtifact, + TInstanceResponse, } from "@/plane-web/types"; import { ESource, EExecutionStatus } from "@/plane-web/types"; import { ArtifactsStore } from "./artifacts"; @@ -36,6 +36,7 @@ export interface IPiChatStore { activeModel: TAiModels | undefined; models: TAiModels[]; isAuthorized: boolean; + isWorkspaceAuthorized: boolean; favoriteChats: string[]; isLoading: boolean; isPiTyping: boolean; @@ -62,7 +63,7 @@ export interface IPiChatStore { callbackUrl: string, attachmentIds: string[] ) => Promise; - getTemplates: (workspaceId: string | undefined) => Promise; + getInstance: (workspaceId: string) => Promise; fetchUserThreads: (workspaceId: string | undefined, isProjectChat: boolean) => void; searchCallback: (workspace: string, query: string, focus: TFocus) => Promise; sendFeedback: ( @@ -120,6 +121,7 @@ export class PiChatStore implements IPiChatStore { models: TAiModels[] = []; activeModel: TAiModels | undefined = undefined; isAuthorized = true; + isWorkspaceAuthorized = true; chatMap: Record = {}; piThreads: string[] = []; projectThreads: string[] = []; @@ -151,6 +153,7 @@ export class PiChatStore implements IPiChatStore { isPiTypingMap: observable, isLoadingMap: observable, isAuthorized: observable, + isWorkspaceAuthorized: observable, isLoadingThreads: observable, favoriteChats: observable, isPiChatDrawerOpen: observable.ref, @@ -163,7 +166,7 @@ export class PiChatStore implements IPiChatStore { // actions initPiChat: action, getAnswer: action, - getTemplates: action, + getInstance: action, fetchUserThreads: action, searchCallback: action, sendFeedback: action, @@ -355,9 +358,10 @@ export class PiChatStore implements IPiChatStore { return payload; }; - getTemplates = async (workspaceId: string | undefined) => { - const response = await this.piChatService.listTemplates(workspaceId); - return response?.templates; + getInstance = async (workspaceId: string): Promise => { + const response = await this.piChatService.getInstance(workspaceId); + this.isWorkspaceAuthorized = response.is_authorized; + return response; }; getStreamingAnswer = async ( diff --git a/apps/web/ee/types/pi-chat.d.ts b/apps/web/ee/types/pi-chat.d.ts index c69a2d7cd1..8c193c06dd 100644 --- a/apps/web/ee/types/pi-chat.d.ts +++ b/apps/web/ee/types/pi-chat.d.ts @@ -252,5 +252,15 @@ export type TPiAttachmentIdMap = { [chatId: string]: string[]; }; +export type TInstanceResponse = + | { + is_authorized: true; + templates: TTemplate[]; + } + | { + is_authorized: false; + oauth_url: string; + }; + // constants export const EDITABLE_ARTIFACT_TYPES = ["workitem", "epic", "page"];