[PAI-979] Chore pi default oauth check (#4744)

This commit is contained in:
Akshita Goyal
2025-11-24 16:57:32 +05:30
committed by GitHub
parent 81271ea4f2
commit 7794f8ff7d
15 changed files with 382 additions and 197 deletions
+69
View File
@@ -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=<uuid>
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:
+127 -139
View File
@@ -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)
+14
View File
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+16 -8
View File
@@ -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(() => {
</Link>
</Tooltip>
</div>
<InputBox
isFullScreen
isProjectLevel
showProgress // Required since its taken to a whole different page
className="relative bg-transparent mt-2 max-w-[950px] mx-auto w-full"
activeChatId={activeChatId}
/>
{isWorkspaceAuthorized ? (
<InputBox
isFullScreen
isProjectLevel
showProgress // Required since its taken to a whole different page
className="relative bg-transparent mt-2 max-w-[950px] mx-auto w-full"
activeChatId={activeChatId}
/>
) : (
<UnauthorizedView
className="border border-custom-border-100 rounded-lg p-4 mt-3 max-h-[164px] justify-start"
imgClassName="h-[117px]"
/>
)}
</div>
</WithFeatureFlagHOC>
);
@@ -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
<span className="!underline !text-custom-text-350">{children}</span>
) : (
<a href={href || ""} rel="noopener noreferrer">
{children}
</a>
),
a: ({ children, href }) => (
<a href={href || ""} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto w-full my-4 border-custom-border-200">
<table className="min-w-full border-collapse">{children}</table>
@@ -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 */}
<div className="flex text-sm font-medium gap-1 cursor-pointer">
<Tooltip tooltipContent="Convert to page" position="bottom" className="mb-4">
<button type="button" onClick={handleConvertToPage}>
<FilePlus2 size={16} className="my-auto text-custom-text-300 transition-colors " />
<Tooltip
tooltipContent={isWorkspaceAuthorized ? "Convert to page" : "Authorize workspace to convert to page"}
position="bottom"
className="mb-4"
>
<button onClick={() => isWorkspaceAuthorized && handleConvertToPage?.()}>
<FilePlus2
size={16}
className={cn("my-auto text-custom-text-300 transition-colors", {
"cursor-not-allowed text-custom-text-400": !isWorkspaceAuthorized,
})}
/>
</button>
</Tooltip>
</div>
</div>
);
};
});
@@ -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<string | null>(null);
if (!currentUser) return null;
if (!isLoading && !instance?.is_authorized) return <UnauthorizedView />;
return (
<div
className={cn("m-auto pb-[180px]", {
@@ -54,17 +61,18 @@ export const NewConversation = observer((props: TProps) => {
</Loader>
</div>
) : (
<div className="flex gap-4 flex-wrap justify-center ">
{templates?.map((prompt, index) => (
<SystemPrompts
key={index}
prompt={prompt}
shouldRedirect={shouldRedirect}
isProjectLevel={isProjectLevel}
isInitializing={isInitializing === prompt.text}
setIsInitializing={(value) => setIsInitializing(value)}
/>
))}
<div className="flex gap-4 flex-wrap m-auto justify-center mt-6">
{instance?.is_authorized &&
instance?.templates?.map((prompt: TTemplate, index: number) => (
<SystemPrompts
key={index}
prompt={prompt}
shouldRedirect={shouldRedirect}
isProjectLevel={isProjectLevel}
isInitializing={isInitializing === prompt.text}
setIsInitializing={(value) => setIsInitializing(value)}
/>
))}
</div>
)}
</div>
+6 -3
View File
@@ -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 ? (
<div
className={cn(
"px-page-x relative flex flex-col h-[90%] flex-1 align-middle justify-center max-w-[400px] md:m-auto w-full",
@@ -75,8 +76,10 @@ export const PiChatDetail = observer((props: TProps) => {
/>
</div>
</div>
) : (
) : isWorkspaceAuthorized ? (
<NotAuthorizedView className="bg-transparent" />
) : (
<UnauthorizedView />
)}
</>
);
@@ -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 (
<div className={"@container w-full h-full"}>
<div
className={cn("flex @[400px]:flex-row flex-col size-full items-center justify-center gap-8 px-8", className)}
>
<div className="flex max-h-full bg-custom-background-90 p-12 pr-0 rounded-lg items-center max-w-[350px] overflow-hidden shadow-r-md justify-end">
<img
className={cn("w-auto", imgClassName)}
src={resolveGeneralTheme(resolvedTheme) === "dark" ? darkState : lightState}
alt="Unauthorized"
/>
</div>
<div className="flex flex-col gap-4 max-w-[400px]">
<div className="flex flex-col">
<div className="text-lg font-semibold leading-7 text-custom-text-100">
Plane AI can now take actions for you.
</div>
<div className="text-sm leading-5 text-custom-text-300">
Use Build mode to create work items, cycles and more. Activate now to start Plane AI actions.
</div>
</div>
{instance && !instance?.is_authorized && (
<Link
href={`${instance.oauth_url}?sidebar_open_url=${pathname}${isPiChatDrawerOpen ? "?pi_sidebar_open=true" : ""}`}
className={cn(getButtonStyling("primary", "md"), "w-fit")}
>
Activate Build mode
</Link>
)}
</div>
</div>
</div>
);
};
+10 -1
View File
@@ -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<IWorkspaceAuthWrapper> = observer((props) => {
// props
const { children } = props;
@@ -55,6 +56,8 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = 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<IWorkspaceAuthWrapper> = 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<IWorkspaceAuthWrapper> = 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;
+5 -8
View File
@@ -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<TTemplateResponse> {
return this.get(`/api/v1/chat/get-templates/`, {
// fetch instance
async getInstance(workspaceId: string): Promise<TInstanceResponse> {
return this.get(`/api/v1/chat/start/`, {
params: {
...(workspaceId && { workspace_id: workspaceId }),
workspace_id: workspaceId,
},
})
.then((response) => response?.data)
+10 -6
View File
@@ -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<void>;
getTemplates: (workspaceId: string | undefined) => Promise<TTemplate[]>;
getInstance: (workspaceId: string) => Promise<TInstanceResponse>;
fetchUserThreads: (workspaceId: string | undefined, isProjectChat: boolean) => void;
searchCallback: (workspace: string, query: string, focus: TFocus) => Promise<IFormattedValue>;
sendFeedback: (
@@ -120,6 +121,7 @@ export class PiChatStore implements IPiChatStore {
models: TAiModels[] = [];
activeModel: TAiModels | undefined = undefined;
isAuthorized = true;
isWorkspaceAuthorized = true;
chatMap: Record<string, TChatHistory> = {};
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<TInstanceResponse> => {
const response = await this.piChatService.getInstance(workspaceId);
this.isWorkspaceAuthorized = response.is_authorized;
return response;
};
getStreamingAnswer = async (
+10
View File
@@ -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"];