From 8d6accaf6082909c59a84502fa26883f40cb0c61 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 4 Mar 2026 19:18:06 -0500 Subject: [PATCH] feat: add account management, email verification, AI fixes, and user guides - Profile settings, account transfer, delete/leave account flows - Email verification with JWT tokens and Resend integration - AI assistant/copilot fixes: markdown rendering, shared RAG helpers, token tracking, input refocus, model_validate usage - User guides hub + detail pages with 13 topic guides - Sidebar and top bar navigation for guides Co-Authored-By: Claude Opus 4.6 --- .../versions/040_add_user_profile_fields.py | 30 ++ .../041_add_email_verification_tokens.py | 30 ++ .../042_add_pgvector_and_tree_embeddings.py | 7 +- backend/app/api/endpoints/accounts.py | 149 +++++- backend/app/api/endpoints/assistant_chat.py | 2 +- backend/app/api/endpoints/auth.py | 146 +++++- backend/app/api/endpoints/copilot.py | 18 +- backend/app/core/email.py | 65 +++ backend/app/core/security.py | 13 + .../app/models/email_verification_token.py | 42 ++ backend/app/models/user.py | 9 + backend/app/schemas/account.py | 5 + backend/app/schemas/user.py | 9 + .../app/services/assistant_chat_service.py | 38 +- backend/app/services/copilot_service.py | 53 +- backend/app/services/rag_service.py | 39 ++ backend/tests/test_account_lifecycle.py | 109 ++++ backend/tests/test_account_transfer.py | 63 +++ backend/tests/test_auth_profile.py | 90 ++++ backend/tests/test_email_verification.py | 57 ++ docker-compose.dev.yml | 2 +- frontend/src/api/accounts.ts | 16 + frontend/src/api/auth.ts | 15 +- .../components/account/DeleteAccountModal.tsx | 92 ++++ .../components/account/LeaveAccountModal.tsx | 67 +++ .../account/TransferOwnershipModal.tsx | 115 ++++ .../src/components/assistant/ChatMessage.tsx | 3 +- .../src/components/copilot/CopilotPanel.tsx | 37 +- frontend/src/components/guides/GuideCard.tsx | 34 ++ .../src/components/guides/GuideSection.tsx | 49 ++ frontend/src/components/layout/AppLayout.tsx | 2 + .../layout/EmailVerificationBanner.tsx | 50 ++ frontend/src/components/layout/Sidebar.tsx | 4 +- frontend/src/components/layout/TopBar.tsx | 9 +- frontend/src/data/guides.ts | 495 ++++++++++++++++++ frontend/src/pages/AssistantChatPage.tsx | 12 +- frontend/src/pages/GuideDetailPage.tsx | 78 +++ frontend/src/pages/GuidesHubPage.tsx | 29 + frontend/src/pages/VerifyEmailPage.tsx | 75 +++ .../src/pages/account/ProfileSettingsPage.tsx | 184 +++++++ frontend/src/router.tsx | 18 + frontend/src/types/assistant-chat.ts | 4 +- frontend/src/types/index.ts | 2 +- frontend/src/types/user.ts | 9 + frontend/vite.config.ts | 6 + 45 files changed, 2255 insertions(+), 126 deletions(-) create mode 100644 backend/alembic/versions/040_add_user_profile_fields.py create mode 100644 backend/alembic/versions/041_add_email_verification_tokens.py create mode 100644 backend/app/models/email_verification_token.py create mode 100644 backend/tests/test_account_lifecycle.py create mode 100644 backend/tests/test_account_transfer.py create mode 100644 backend/tests/test_auth_profile.py create mode 100644 backend/tests/test_email_verification.py create mode 100644 frontend/src/components/account/DeleteAccountModal.tsx create mode 100644 frontend/src/components/account/LeaveAccountModal.tsx create mode 100644 frontend/src/components/account/TransferOwnershipModal.tsx create mode 100644 frontend/src/components/guides/GuideCard.tsx create mode 100644 frontend/src/components/guides/GuideSection.tsx create mode 100644 frontend/src/components/layout/EmailVerificationBanner.tsx create mode 100644 frontend/src/data/guides.ts create mode 100644 frontend/src/pages/GuideDetailPage.tsx create mode 100644 frontend/src/pages/GuidesHubPage.tsx create mode 100644 frontend/src/pages/VerifyEmailPage.tsx create mode 100644 frontend/src/pages/account/ProfileSettingsPage.tsx diff --git a/backend/alembic/versions/040_add_user_profile_fields.py b/backend/alembic/versions/040_add_user_profile_fields.py new file mode 100644 index 00000000..39e838ce --- /dev/null +++ b/backend/alembic/versions/040_add_user_profile_fields.py @@ -0,0 +1,30 @@ +"""Add user profile fields (phone, job_title, timezone, avatar_url, email_verified_at) + +Revision ID: 040 +Revises: fb1481317ff6 +Create Date: 2026-03-03 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = "040" +down_revision = "e2d81e82ea5e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("phone", sa.String(50), nullable=True)) + op.add_column("users", sa.Column("job_title", sa.String(255), nullable=True)) + op.add_column("users", sa.Column("timezone", sa.String(100), nullable=False, server_default="UTC")) + op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True)) + op.add_column("users", sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "email_verified_at") + op.drop_column("users", "avatar_url") + op.drop_column("users", "timezone") + op.drop_column("users", "job_title") + op.drop_column("users", "phone") diff --git a/backend/alembic/versions/041_add_email_verification_tokens.py b/backend/alembic/versions/041_add_email_verification_tokens.py new file mode 100644 index 00000000..6003faf8 --- /dev/null +++ b/backend/alembic/versions/041_add_email_verification_tokens.py @@ -0,0 +1,30 @@ +"""Add email_verification_tokens table + +Revision ID: 041 +Revises: 040 +Create Date: 2026-03-03 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "041" +down_revision = "040" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "email_verification_tokens", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("token_hash", sa.String(64), unique=True, nullable=False, index=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("email_verification_tokens") diff --git a/backend/alembic/versions/042_add_pgvector_and_tree_embeddings.py b/backend/alembic/versions/042_add_pgvector_and_tree_embeddings.py index 4ce18be2..23ef2438 100644 --- a/backend/alembic/versions/042_add_pgvector_and_tree_embeddings.py +++ b/backend/alembic/versions/042_add_pgvector_and_tree_embeddings.py @@ -18,7 +18,6 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - # Enable pgvector extension op.execute("CREATE EXTENSION IF NOT EXISTS vector") op.create_table( @@ -31,21 +30,17 @@ def upgrade() -> None: sa.Column("node_id", sa.String(100), nullable=True), sa.Column("chunk_text", sa.Text(), nullable=False), sa.Column("embedding_model", sa.String(50), nullable=False, server_default="voyage-3.5"), - sa.Column("embedding", sa.Column.__class__, nullable=True), # placeholder, replaced below sa.Column("meta", postgresql.JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), ) - # Drop the placeholder embedding column and add the vector column - op.drop_column("tree_embeddings", "embedding") op.execute("ALTER TABLE tree_embeddings ADD COLUMN embedding vector(1024)") - # Indexes op.create_index("ix_tree_embeddings_account_id", "tree_embeddings", ["account_id"]) op.create_index("ix_tree_embeddings_tree_id", "tree_embeddings", ["tree_id"]) def downgrade() -> None: op.drop_table("tree_embeddings") - op.execute("DROP EXTENSION IF EXISTS vector") + op.execute("DROP EXTENSION IF EXISTS vector") \ No newline at end of file diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index b266f2b1..91bb7fa8 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -7,17 +7,20 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from pydantic import BaseModel from app.core.database import get_db from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage from app.core.audit import log_audit +from app.models.refresh_token import RefreshToken from app.core.email import EmailService from app.models.account import Account from app.models.account_invite import AccountInvite from app.models.subscription import Subscription from app.models.user import User -from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse +from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails from app.schemas.user import UserResponse, AccountRoleUpdate +from app.core.security import verify_password from app.api.deps import get_current_active_user, require_account_owner router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -142,6 +145,58 @@ async def update_member_role( return user +@router.post("/me/transfer-ownership", response_model=AccountResponse) +async def transfer_ownership( + data: TransferOwnershipRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_account_owner)] +): + """Transfer account ownership to another member (owner only).""" + if not verify_password(data.current_password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Current password is incorrect" + ) + + if data.target_user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot transfer ownership to yourself" + ) + + result = await db.execute( + select(User).where( + User.id == data.target_user_id, + User.account_id == current_user.account_id + ) + ) + target_user = result.scalar_one_or_none() + if not target_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found in your account" + ) + + # Swap roles + current_user.account_role = "engineer" + target_user.account_role = "owner" + + # Update account owner + result = await db.execute( + select(Account).where(Account.id == current_user.account_id) + ) + account = result.scalar_one() + account.owner_id = target_user.id + + await log_audit( + db, current_user.id, "account.ownership_transfer", "account", account.id, + {"new_owner_id": str(target_user.id)} + ) + await db.commit() + await db.refresh(account) + return account + + @router.delete("/me/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_member( user_id: UUID, @@ -318,3 +373,95 @@ async def list_invites( .order_by(AccountInvite.created_at.desc()) ) return result.scalars().all() + + +@router.post("/me/leave") +async def leave_account( + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)] +): + """Leave the current account (non-owners only). Creates a personal account.""" + if current_user.account_role == "owner": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Account owners cannot leave. Transfer ownership first." + ) + + # Create a personal account (same pattern as remove_member) + chars = string.ascii_uppercase + string.digits + display_code = ''.join(secrets.choice(chars) for _ in range(8)) + + new_account = Account( + name=f"{current_user.name}'s Account", + display_code=display_code, + owner_id=current_user.id, + ) + db.add(new_account) + await db.flush() + + new_sub = Subscription( + account_id=new_account.id, + plan="free", + status="active", + ) + db.add(new_sub) + + old_account_id = current_user.account_id + current_user.account_id = new_account.id + current_user.account_role = "owner" + + await log_audit(db, current_user.id, "account.leave", "account", old_account_id) + await db.commit() + + return {"message": "You have left the account"} + + +class DeleteAccountRequest(BaseModel): + current_password: str + + +@router.delete("/me") +async def delete_account( + data: DeleteAccountRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_account_owner)] +): + """Delete the current account and soft-delete the user (owner only, no other members).""" + if not verify_password(data.current_password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Current password is incorrect" + ) + + # Check no other members + result = await db.execute( + select(User).where( + User.account_id == current_user.account_id, + User.id != current_user.id, + User.deleted_at.is_(None) + ) + ) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete account with other members. Remove them first." + ) + + # Soft-delete user + current_user.deleted_at = datetime.now(timezone.utc) + current_user.is_active = False + + # Revoke all refresh tokens + rt_result = await db.execute( + select(RefreshToken).where( + RefreshToken.user_id == current_user.id, + RefreshToken.revoked_at.is_(None) + ) + ) + for rt in rt_result.scalars().all(): + rt.revoked_at = datetime.now(timezone.utc) + + await log_audit(db, current_user.id, "account.delete", "account", current_user.account_id) + await db.commit() + + return {"message": "Account deleted"} diff --git a/backend/app/api/endpoints/assistant_chat.py b/backend/app/api/endpoints/assistant_chat.py index 2dff0552..5edf53c5 100644 --- a/backend/app/api/endpoints/assistant_chat.py +++ b/backend/app/api/endpoints/assistant_chat.py @@ -198,7 +198,7 @@ async def post_message( return ChatMessageResponse( content=ai_content, - suggested_flows=[SuggestedFlow(**sf) for sf in suggested_flows], + suggested_flows=[SuggestedFlow.model_validate(sf) for sf in suggested_flows], ) diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 42109421..4b3f0295 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -15,6 +15,7 @@ from app.core.security import ( create_access_token, create_refresh_token, create_password_reset_token, + create_email_verification_token, decode_token, hash_token, ) @@ -24,7 +25,7 @@ from app.models.refresh_token import RefreshToken from app.models.account import Account from app.models.subscription import Subscription from app.models.account_invite import AccountInvite -from app.schemas.user import UserCreate, UserResponse, UserLogin +from app.schemas.user import UserCreate, UserResponse, UserLogin, UserUpdate from app.schemas.token import Token from app.schemas.auth_password import ( ChangePasswordRequest, @@ -34,6 +35,7 @@ from app.schemas.auth_password import ( ResetPasswordRequest, ) from app.models.password_reset_token import PasswordResetToken +from app.models.email_verification_token import EmailVerificationToken from app.core.email import EmailService from app.api.deps import get_current_active_user, get_refresh_token_payload from app.core.audit import log_audit @@ -351,6 +353,54 @@ async def get_me( return current_user +@router.patch("/me", response_model=UserResponse) +async def update_me( + data: UserUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)] +): + """Update current user's profile (name, email).""" + update_fields = data.model_fields_set - {"current_password"} + if not update_fields: + return current_user + + # Email change requires current_password + if "email" in data.model_fields_set: + if not data.current_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is required to change email" + ) + if not verify_password(data.current_password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Current password is incorrect" + ) + # Check uniqueness + result = await db.execute( + select(User).where(User.email == data.email, User.id != current_user.id) + ) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + current_user.email = data.email + + if "name" in data.model_fields_set and data.name is not None: + current_user.name = data.name + + # Handle simple string profile fields + for field in ("phone", "job_title", "timezone"): + if field in data.model_fields_set: + setattr(current_user, field, getattr(data, field)) + + await log_audit(db, current_user.id, "auth.profile_update", "user", current_user.id) + await db.commit() + await db.refresh(current_user) + return current_user + + @router.post("/logout") async def logout( payload: Annotated[dict, Depends(get_refresh_token_payload)], @@ -543,3 +593,97 @@ async def reset_password( await db.commit() return {"message": "Password has been reset successfully"} + + +@router.post("/email/send-verification") +@limiter.limit("3/minute") +async def send_verification_email( + request: Request, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)] +): + """Send an email verification link to the current user.""" + if current_user.email_verified_at is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email is already verified" + ) + + raw_token = create_email_verification_token(str(current_user.id)) + payload = decode_token(raw_token) + if payload and payload.get("jti"): + token_record = EmailVerificationToken( + token_hash=hash_token(payload["jti"]), + user_id=current_user.id, + expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc), + ) + db.add(token_record) + await db.commit() + + verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}" + await EmailService.send_email_verification_email( + to_email=current_user.email, + verification_url=verification_url, + ) + + return {"message": "Verification email sent"} + + +@router.post("/email/verify") +async def verify_email( + data: dict, + db: Annotated[AsyncSession, Depends(get_db)] +): + """Verify an email using a token. Public endpoint.""" + token = data.get("token") + if not token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token is required" + ) + + payload = decode_token(token) + if not payload or payload.get("type") != "email_verification": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired verification token" + ) + + jti = payload.get("jti") + user_id = payload.get("sub") + if not jti or not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid verification token" + ) + + result = await db.execute( + select(EmailVerificationToken).where( + EmailVerificationToken.token_hash == hash_token(jti) + ) + ) + token_record = result.scalar_one_or_none() + + if not token_record or not token_record.is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verification token has already been used or has expired" + ) + + # Mark token as used + token_record.used_at = datetime.now(timezone.utc) + + # Mark user email as verified + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid verification token" + ) + + user.email_verified_at = datetime.now(timezone.utc) + await log_audit(db, user.id, "auth.email_verified", "user", user.id) + await db.commit() + + return {"message": "Email verified successfully"} diff --git a/backend/app/api/endpoints/copilot.py b/backend/app/api/endpoints/copilot.py index 3cc40b73..31318f76 100644 --- a/backend/app/api/endpoints/copilot.py +++ b/backend/app/api/endpoints/copilot.py @@ -10,6 +10,7 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.rate_limit import limiter @@ -25,6 +26,7 @@ from app.schemas.copilot import ( CopilotConversationResponse, SuggestedFlow, ) +from app.models.copilot_conversation import CopilotConversation from app.services import copilot_service logger = logging.getLogger(__name__) @@ -112,7 +114,7 @@ async def post_message( plan = await get_user_plan(current_user.account_id, db) try: - ai_content, suggested_flows = await copilot_service.send_message( + ai_content, suggested_flows, conversation = await copilot_service.send_message( conversation_id=conversation_id, user_id=current_user.id, message=data.message, @@ -150,9 +152,12 @@ async def post_message( conversation_id=None, generation_type="copilot_message", tier=plan, - input_tokens=0, - output_tokens=0, - estimated_cost=0, + input_tokens=conversation.total_input_tokens, + output_tokens=conversation.total_output_tokens, + estimated_cost=( + conversation.total_input_tokens * 1.0 / 1_000_000 + + conversation.total_output_tokens * 5.0 / 1_000_000 + ), succeeded=True, counts_toward_quota=False, error_code=None, @@ -163,7 +168,7 @@ async def post_message( return CopilotMessageResponse( content=ai_content, - suggested_flows=[SuggestedFlow(**sf) for sf in suggested_flows], + suggested_flows=[SuggestedFlow.model_validate(sf) for sf in suggested_flows], ) @@ -174,9 +179,6 @@ async def get_conversation( db: Annotated[AsyncSession, Depends(get_db)], ): """Get copilot conversation history.""" - from sqlalchemy import select - from app.models.copilot_conversation import CopilotConversation - result = await db.execute( select(CopilotConversation).where( CopilotConversation.id == conversation_id, diff --git a/backend/app/core/email.py b/backend/app/core/email.py index a5dc036a..908715eb 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -163,6 +163,39 @@ class EmailService: logger.exception("Failed to send account invite email to %s", to_email) return False + @staticmethod + async def send_email_verification_email( + to_email: str, + verification_url: str, + ) -> bool: + if not settings.email_enabled: + logger.warning("Email not sent — RESEND_API_KEY not configured") + return False + + try: + import resend + + resend.api_key = settings.RESEND_API_KEY + + subject = "Verify Your Email — ResolutionFlow" + + html = _render_email_verification_html(verification_url=verification_url) + + resend.Emails.send( + { + "from": settings.FROM_EMAIL, + "to": [to_email], + "subject": subject, + "html": html, + } + ) + logger.info("Verification email sent to %s", to_email) + return True + + except Exception: + logger.exception("Failed to send verification email to %s", to_email) + return False + @staticmethod async def send_feedback_email( to_email: str, @@ -485,6 +518,38 @@ def _render_feedback_html( """ +def _render_email_verification_html(verification_url: str) -> str: + return f""" + + + + +
+ + + + + +
+

ResolutionFlow

+

Decision Tree Platform for MSP Professionals

+
+

+ Please verify your email address by clicking the button below. This link expires in 24 hours. +

+
+ + Verify Email + +
+

+ If you didn't create an account, you can safely ignore this email. +

+
+
+""" + + def _render_feedback_confirmation_html( feedback_type: str, message_preview: str, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 6f8b5f01..f5e2f460 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -70,6 +70,19 @@ def create_password_reset_token(user_id: str) -> str: return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) +def create_email_verification_token(user_id: str) -> str: + """Create a JWT email verification token (24-hour expiry, unique JTI).""" + jti = str(uuid.uuid4()) + expire = datetime.now(timezone.utc) + timedelta(hours=24) + to_encode = { + "sub": user_id, + "type": "email_verification", + "jti": jti, + "exp": expire, + } + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + def generate_temp_password(length: int = 16) -> str: """Generate a temporary password with guaranteed complexity. diff --git a/backend/app/models/email_verification_token.py b/backend/app/models/email_verification_token.py new file mode 100644 index 00000000..85e5a2e0 --- /dev/null +++ b/backend/app/models/email_verification_token.py @@ -0,0 +1,42 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class EmailVerificationToken(Base): + __tablename__ = "email_verification_tokens" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4 + ) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False, + index=True + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc) + ) + + @property + def is_used(self) -> bool: + return self.used_at is not None + + @property + def is_expired(self) -> bool: + return datetime.now(timezone.utc) > self.expires_at + + @property + def is_valid(self) -> bool: + return not self.is_used and not self.is_expired diff --git a/backend/app/models/user.py b/backend/app/models/user.py index ba6ba2b1..d385fcfb 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -68,6 +68,15 @@ class User(Base): ) last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + # Profile fields + phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC") + avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + email_verified_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + # AI billing cycle anchor (for quota reset calculation) ai_billing_cycle_anchor_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True diff --git a/backend/app/schemas/account.py b/backend/app/schemas/account.py index 8a9a101e..6909d3d7 100644 --- a/backend/app/schemas/account.py +++ b/backend/app/schemas/account.py @@ -20,6 +20,11 @@ class AccountUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) +class TransferOwnershipRequest(BaseModel): + current_password: str + target_user_id: UUID + + class AccountInviteCreate(BaseModel): email: str = Field(..., max_length=255) role: str = Field("engineer", pattern="^(engineer|viewer)$") diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 5b580e67..496c46e9 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -30,6 +30,10 @@ class UserCreate(UserBase): class UserUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) email: Optional[EmailStr] = None + current_password: Optional[str] = Field(None, description="Required when changing email") + phone: Optional[str] = Field(None, max_length=50) + job_title: Optional[str] = Field(None, max_length=255) + timezone: Optional[str] = Field(None, max_length=100) class UserLogin(BaseModel): @@ -48,6 +52,11 @@ class UserResponse(UserBase): created_at: datetime last_login: Optional[datetime] = None deleted_at: Optional[datetime] = None + phone: Optional[str] = None + job_title: Optional[str] = None + timezone: str = "UTC" + avatar_url: Optional[str] = None + email_verified_at: Optional[datetime] = None class Config: from_attributes = True diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 130794b2..797a3be7 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.ai_provider import get_ai_provider from app.models.assistant_chat import AssistantChat -from app.services import rag_service +from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows logger = logging.getLogger(__name__) @@ -33,36 +33,6 @@ When answering: """ -def _build_rag_context(rag_results: list[dict[str, Any]]) -> str: - """Format RAG results into a system prompt section.""" - if not rag_results: - return "" - - parts = ["\n--- RELEVANT FLOWS FROM TEAM LIBRARY ---"] - for r in rag_results[:5]: - parts.append(f"- [{r['tree_type']}] {r['tree_name']}: {r['chunk_text'][:200]}") - - return "\n".join(parts) - - -def _extract_suggested_flows(rag_results: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Extract unique suggested flows from RAG results.""" - seen: set[str] = set() - suggestions = [] - for r in rag_results: - tid = r["tree_id"] - if tid in seen or r["similarity"] < 0.3: - continue - seen.add(tid) - suggestions.append({ - "tree_id": tid, - "tree_name": r["tree_name"], - "tree_type": r["tree_type"], - "relevance_snippet": r["chunk_text"][:150], - }) - return suggestions[:3] - - def _auto_title(message: str) -> str: """Generate a short title from the first user message.""" title = message.strip()[:100] @@ -113,7 +83,7 @@ async def send_message( chat.title = _auto_title(message) # RAG search - rag_results = await rag_service.search( + rag_results = await rag_search( query=message, account_id=account_id, db=db, @@ -121,7 +91,7 @@ async def send_message( ) # Build system prompt - system_prompt = ASSISTANT_SYSTEM_PROMPT + _build_rag_context(rag_results) + system_prompt = ASSISTANT_SYSTEM_PROMPT + build_rag_context(rag_results) # Build messages for AI ai_messages = [] @@ -147,6 +117,6 @@ async def send_message( chat.total_input_tokens += input_tokens chat.total_output_tokens += output_tokens - suggested_flows = _extract_suggested_flows(rag_results) + suggested_flows = extract_suggested_flows(rag_results) return ai_content, suggested_flows, chat diff --git a/backend/app/services/copilot_service.py b/backend/app/services/copilot_service.py index 2f09017d..b8211e6f 100644 --- a/backend/app/services/copilot_service.py +++ b/backend/app/services/copilot_service.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import selectinload from app.core.ai_provider import get_ai_provider from app.models.tree import Tree from app.models.copilot_conversation import CopilotConversation -from app.services import rag_service +from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows logger = logging.getLogger(__name__) @@ -83,45 +83,6 @@ def _find_node(structure: dict, node_id: str) -> Optional[dict]: return None -def _build_rag_context(rag_results: list[dict[str, Any]]) -> str: - """Format RAG results into a system prompt section.""" - if not rag_results: - return "" - - parts = ["\n--- RELEVANT FLOWS FROM TEAM LIBRARY ---"] - for r in rag_results[:5]: # Cap at 5 for prompt size - parts.append(f"- [{r['tree_type']}] {r['tree_name']}: {r['chunk_text'][:200]}") - - return "\n".join(parts) - - -def _extract_suggested_flows( - rag_results: list[dict[str, Any]], - exclude_tree_id: Optional[UUID] = None, -) -> list[dict[str, Any]]: - """Extract unique suggested flows from RAG results.""" - seen_tree_ids: set[str] = set() - suggestions = [] - - for r in rag_results: - tid = r["tree_id"] - if exclude_tree_id and tid == str(exclude_tree_id): - continue - if tid in seen_tree_ids: - continue - if r["similarity"] < 0.3: - continue - seen_tree_ids.add(tid) - suggestions.append({ - "tree_id": tid, - "tree_name": r["tree_name"], - "tree_type": r["tree_type"], - "relevance_snippet": r["chunk_text"][:150], - }) - - return suggestions[:3] - - async def start_conversation( user_id: UUID, account_id: UUID, @@ -168,10 +129,10 @@ async def send_message( message: str, current_node_id: Optional[str], db: AsyncSession, -) -> tuple[str, list[dict[str, Any]]]: +) -> tuple[str, list[dict[str, Any]], CopilotConversation]: """Send a user message and get AI response. - Returns (ai_content, suggested_flows). + Returns (ai_content, suggested_flows, conversation). """ result = await db.execute( select(CopilotConversation).where( @@ -199,7 +160,7 @@ async def send_message( conversation.current_node_id = current_node_id # RAG search - rag_results = await rag_service.search( + rag_results = await rag_search( query=message, account_id=conversation.account_id, db=db, @@ -209,7 +170,7 @@ async def send_message( # Build system prompt system_prompt = COPILOT_SYSTEM_PROMPT system_prompt += _build_flow_context(tree, conversation.current_node_id) - system_prompt += _build_rag_context(rag_results) + system_prompt += build_rag_context(rag_results) # Build messages for AI ai_messages = [] @@ -236,6 +197,6 @@ async def send_message( conversation.total_output_tokens += output_tokens # Extract suggested flows - suggested_flows = _extract_suggested_flows(rag_results, exclude_tree_id=tree.id) + suggested_flows = extract_suggested_flows(rag_results, exclude_tree_id=tree.id) - return ai_content, suggested_flows + return ai_content, suggested_flows, conversation diff --git a/backend/app/services/rag_service.py b/backend/app/services/rag_service.py index fe0cc1bf..080c0098 100644 --- a/backend/app/services/rag_service.py +++ b/backend/app/services/rag_service.py @@ -168,3 +168,42 @@ async def search( } for row in rows ] + + +def build_rag_context(rag_results: list[dict[str, Any]]) -> str: + """Format RAG results into a system prompt section.""" + if not rag_results: + return "" + + parts = ["\n--- RELEVANT FLOWS FROM TEAM LIBRARY ---"] + for r in rag_results[:5]: # Cap at 5 for prompt size + parts.append(f"- [{r['tree_type']}] {r['tree_name']}: {r['chunk_text'][:200]}") + + return "\n".join(parts) + + +def extract_suggested_flows( + rag_results: list[dict[str, Any]], + exclude_tree_id: Optional[UUID] = None, +) -> list[dict[str, Any]]: + """Extract unique suggested flows from RAG results.""" + seen_tree_ids: set[str] = set() + suggestions = [] + + for r in rag_results: + tid = r["tree_id"] + if exclude_tree_id and tid == str(exclude_tree_id): + continue + if tid in seen_tree_ids: + continue + if r["similarity"] < 0.3: + continue + seen_tree_ids.add(tid) + suggestions.append({ + "tree_id": tid, + "tree_name": r["tree_name"], + "tree_type": r["tree_type"], + "relevance_snippet": r["chunk_text"][:150], + }) + + return suggestions[:3] diff --git a/backend/tests/test_account_lifecycle.py b/backend/tests/test_account_lifecycle.py new file mode 100644 index 00000000..2a7e9686 --- /dev/null +++ b/backend/tests/test_account_lifecycle.py @@ -0,0 +1,109 @@ +"""Tests for leave account and delete account endpoints.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestLeaveAccount: + """Test POST /accounts/me/leave.""" + + async def test_leave_as_non_owner(self, client: AsyncClient, test_db): + """Non-owner can leave and gets a personal account.""" + from sqlalchemy import select + from app.models.user import User + + # Register owner + owner = await client.post("/api/v1/auth/register", json={ + "email": "owner@example.com", "password": "TestPassword123!", "name": "Owner", + }) + assert owner.status_code == 201 + owner_data = owner.json() + + # Login as owner + login = await client.post("/api/v1/auth/login/json", json={ + "email": "owner@example.com", "password": "TestPassword123!", + }) + owner_headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + + # Register member + member = await client.post("/api/v1/auth/register", json={ + "email": "member@example.com", "password": "TestPassword123!", "name": "Member", + }) + member_id = member.json()["id"] + + # Move member to owner's account + result = await test_db.execute(select(User).where(User.id == member_id)) + member_user = result.scalar_one() + member_user.account_id = owner_data["account_id"] + member_user.account_role = "engineer" + await test_db.commit() + + # Login as member + login = await client.post("/api/v1/auth/login/json", json={ + "email": "member@example.com", "password": "TestPassword123!", + }) + member_headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + + # Leave + response = await client.post("/api/v1/accounts/me/leave", headers=member_headers) + assert response.status_code == 200 + + async def test_leave_as_owner_fails(self, client: AsyncClient, auth_headers: dict): + """Owner cannot leave their own account.""" + response = await client.post("/api/v1/accounts/me/leave", headers=auth_headers) + assert response.status_code == 400 + + +@pytest.mark.asyncio +class TestDeleteAccount: + """Test DELETE /accounts/me.""" + + async def test_delete_success(self, client: AsyncClient, auth_headers: dict): + """Owner with no other members can delete account.""" + response = await client.request( + "DELETE", + "/api/v1/accounts/me", + json={"current_password": "TestPassword123!"}, + headers=auth_headers, + ) + assert response.status_code == 200 + + async def test_delete_wrong_password(self, client: AsyncClient, auth_headers: dict): + """Wrong password returns 401.""" + response = await client.request( + "DELETE", + "/api/v1/accounts/me", + json={"current_password": "WrongPassword123!"}, + headers=auth_headers, + ) + assert response.status_code == 401 + + async def test_delete_with_members_fails(self, client: AsyncClient, auth_headers: dict, test_db): + """Cannot delete account that has other members.""" + from sqlalchemy import select + from app.models.user import User + + # Get owner's account_id + me = await client.get("/api/v1/auth/me", headers=auth_headers) + account_id = me.json()["account_id"] + + # Register and add member + member = await client.post("/api/v1/auth/register", json={ + "email": "member2@example.com", "password": "TestPassword123!", "name": "Member", + }) + member_id = member.json()["id"] + + result = await test_db.execute(select(User).where(User.id == member_id)) + member_user = result.scalar_one() + member_user.account_id = account_id + member_user.account_role = "engineer" + await test_db.commit() + + response = await client.request( + "DELETE", + "/api/v1/accounts/me", + json={"current_password": "TestPassword123!"}, + headers=auth_headers, + ) + assert response.status_code == 400 diff --git a/backend/tests/test_account_transfer.py b/backend/tests/test_account_transfer.py new file mode 100644 index 00000000..18af2d88 --- /dev/null +++ b/backend/tests/test_account_transfer.py @@ -0,0 +1,63 @@ +"""Tests for account ownership transfer.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestOwnershipTransfer: + """Test POST /accounts/me/transfer-ownership.""" + + async def _create_member(self, client: AsyncClient, owner_headers: dict, test_db): + """Register a second user and add them to the owner's account.""" + from sqlalchemy import select + from app.models.user import User + + # Register second user (gets own account) + resp = await client.post("/api/v1/auth/register", json={ + "email": "member@example.com", + "password": "TestPassword123!", + "name": "Member User", + }) + assert resp.status_code == 201 + member_id = resp.json()["id"] + + # Get owner's account_id + me = await client.get("/api/v1/auth/me", headers=owner_headers) + owner_account_id = me.json()["account_id"] + + # Move member to owner's account + result = await test_db.execute(select(User).where(User.id == member_id)) + member = result.scalar_one() + member.account_id = owner_account_id + member.account_role = "engineer" + await test_db.commit() + + return member_id + + async def test_transfer_success(self, client: AsyncClient, auth_headers: dict, test_db): + member_id = await self._create_member(client, auth_headers, test_db) + response = await client.post( + "/api/v1/accounts/me/transfer-ownership", + json={"current_password": "TestPassword123!", "target_user_id": member_id}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["owner_id"] == member_id + + async def test_transfer_self(self, client: AsyncClient, auth_headers: dict, test_user): + response = await client.post( + "/api/v1/accounts/me/transfer-ownership", + json={"current_password": "TestPassword123!", "target_user_id": test_user["user_data"]["id"]}, + headers=auth_headers, + ) + assert response.status_code == 400 + + async def test_transfer_wrong_password(self, client: AsyncClient, auth_headers: dict, test_db): + member_id = await self._create_member(client, auth_headers, test_db) + response = await client.post( + "/api/v1/accounts/me/transfer-ownership", + json={"current_password": "WrongPassword123!", "target_user_id": member_id}, + headers=auth_headers, + ) + assert response.status_code == 401 diff --git a/backend/tests/test_auth_profile.py b/backend/tests/test_auth_profile.py new file mode 100644 index 00000000..d91049d4 --- /dev/null +++ b/backend/tests/test_auth_profile.py @@ -0,0 +1,90 @@ +"""Tests for PATCH /auth/me profile update endpoint.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestProfileUpdate: + """Test profile update via PATCH /auth/me.""" + + async def test_update_name(self, client: AsyncClient, auth_headers: dict): + """Name update works without password.""" + response = await client.patch( + "/api/v1/auth/me", + json={"name": "New Name"}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["name"] == "New Name" + + async def test_update_email_with_password(self, client: AsyncClient, auth_headers: dict): + """Email change with correct password succeeds.""" + response = await client.patch( + "/api/v1/auth/me", + json={"email": "newemail@example.com", "current_password": "TestPassword123!"}, + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["email"] == "newemail@example.com" + + async def test_update_email_without_password(self, client: AsyncClient, auth_headers: dict): + """Email change without password returns 400.""" + response = await client.patch( + "/api/v1/auth/me", + json={"email": "newemail@example.com"}, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "password" in response.json()["detail"].lower() + + async def test_update_email_wrong_password(self, client: AsyncClient, auth_headers: dict): + """Email change with wrong password returns 401.""" + response = await client.patch( + "/api/v1/auth/me", + json={"email": "newemail@example.com", "current_password": "WrongPassword123!"}, + headers=auth_headers, + ) + assert response.status_code == 401 + + async def test_update_email_duplicate(self, client: AsyncClient, auth_headers: dict): + """Email change to existing email returns 400.""" + # Register second user + await client.post("/api/v1/auth/register", json={ + "email": "other@example.com", + "password": "TestPassword123!", + "name": "Other User", + }) + + response = await client.patch( + "/api/v1/auth/me", + json={"email": "other@example.com", "current_password": "TestPassword123!"}, + headers=auth_headers, + ) + assert response.status_code == 400 + assert "already registered" in response.json()["detail"].lower() + + async def test_get_me_returns_updated_name(self, client: AsyncClient, auth_headers: dict): + """GET /me reflects the updated profile.""" + await client.patch( + "/api/v1/auth/me", + json={"name": "Updated User"}, + headers=auth_headers, + ) + response = await client.get("/api/v1/auth/me", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["name"] == "Updated User" + + async def test_no_changes_returns_current_user(self, client: AsyncClient, auth_headers: dict): + """Empty update returns current user without error.""" + response = await client.patch( + "/api/v1/auth/me", + json={}, + headers=auth_headers, + ) + assert response.status_code == 200 + + async def test_unauthenticated(self, client: AsyncClient): + """Unauthenticated request returns 401.""" + response = await client.patch("/api/v1/auth/me", json={"name": "X"}) + assert response.status_code == 401 diff --git a/backend/tests/test_email_verification.py b/backend/tests/test_email_verification.py new file mode 100644 index 00000000..34c99a66 --- /dev/null +++ b/backend/tests/test_email_verification.py @@ -0,0 +1,57 @@ +"""Tests for email verification endpoints.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +class TestEmailVerification: + """Test email verification send + verify flow.""" + + async def test_send_verification(self, client: AsyncClient, auth_headers: dict): + """Send verification email returns 200.""" + response = await client.post( + "/api/v1/auth/email/send-verification", + headers=auth_headers, + ) + assert response.status_code == 200 + assert "sent" in response.json()["message"].lower() + + async def test_send_verification_already_verified( + self, client: AsyncClient, auth_headers: dict, test_db + ): + """Returns 400 if email is already verified.""" + from sqlalchemy import select, update + from datetime import datetime, timezone + from app.models.user import User + + # Manually mark email as verified + await test_db.execute( + update(User).where(User.email == "test@example.com").values( + email_verified_at=datetime.now(timezone.utc) + ) + ) + await test_db.commit() + + response = await client.post( + "/api/v1/auth/email/send-verification", + headers=auth_headers, + ) + assert response.status_code == 400 + assert "already verified" in response.json()["detail"].lower() + + async def test_verify_invalid_token(self, client: AsyncClient): + """Invalid token returns 400.""" + response = await client.post( + "/api/v1/auth/email/verify", + json={"token": "invalid-token"}, + ) + assert response.status_code == 400 + + async def test_verify_missing_token(self, client: AsyncClient): + """Missing token returns 400.""" + response = await client.post( + "/api/v1/auth/email/verify", + json={}, + ) + assert response.status_code == 400 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b3fcea27..dd3de5cd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,7 +1,7 @@ name: resolutionflow services: db: - image: postgres:16-alpine + image: pgvector/pgvector:pg16 container_name: resolutionflow_postgres environment: POSTGRES_USER: postgres diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts index 3e1e3b04..44db6b4e 100644 --- a/frontend/src/api/accounts.ts +++ b/frontend/src/api/accounts.ts @@ -48,6 +48,22 @@ export const accountsApi = { const response = await apiClient.post(`/accounts/me/invites/${inviteId}/resend`) return response.data }, + + async transferOwnership(currentPassword: string, targetUserId: string): Promise { + const response = await apiClient.post('/accounts/me/transfer-ownership', { + current_password: currentPassword, + target_user_id: targetUserId, + }) + return response.data + }, + + async leaveAccount(): Promise { + await apiClient.post('/accounts/me/leave') + }, + + async deleteAccount(currentPassword: string): Promise { + await apiClient.delete('/accounts/me', { data: { current_password: currentPassword } }) + }, } export default accountsApi diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index de52bc8b..e4d53bcf 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,5 +1,5 @@ import apiClient from './client' -import type { Token, User, UserCreate, UserLogin } from '@/types' +import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types' export const authApi = { async register(data: UserCreate): Promise { @@ -53,6 +53,19 @@ export const authApi = { new_password: newPassword, }) }, + + async updateProfile(data: UserUpdate): Promise { + const response = await apiClient.patch('/auth/me', data) + return response.data + }, + + async sendVerificationEmail(): Promise { + await apiClient.post('/auth/email/send-verification') + }, + + async verifyEmail(token: string): Promise { + await apiClient.post('/auth/email/verify', { token }) + }, } export default authApi diff --git a/frontend/src/components/account/DeleteAccountModal.tsx b/frontend/src/components/account/DeleteAccountModal.tsx new file mode 100644 index 00000000..8e102024 --- /dev/null +++ b/frontend/src/components/account/DeleteAccountModal.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { Loader2, AlertTriangle } from 'lucide-react' +import { accountsApi } from '@/api/accounts' +import { useAuthStore } from '@/store/authStore' +import { cn } from '@/lib/utils' +import { useNavigate } from 'react-router-dom' + +interface Props { + onClose: () => void +} + +export function DeleteAccountModal({ onClose }: Props) { + const logout = useAuthStore((s) => s.logout) + const navigate = useNavigate() + const [password, setPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleDelete = async (e: React.FormEvent) => { + e.preventDefault() + if (!password) return + + setIsSubmitting(true) + setError(null) + try { + await accountsApi.deleteAccount(password) + await logout() + navigate('/login') + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setError(axiosErr.response?.data?.detail ?? 'Failed to delete account') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+ +

Delete Account

+
+

+ This action is permanent. Your account, data, + and all associated flows will be permanently deleted. +

+ +
+
+ + setPassword(e.target.value)} + required + className={cn( + 'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2', + 'text-foreground focus:border-primary focus:outline-none' + )} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/account/LeaveAccountModal.tsx b/frontend/src/components/account/LeaveAccountModal.tsx new file mode 100644 index 00000000..500d314d --- /dev/null +++ b/frontend/src/components/account/LeaveAccountModal.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { Loader2, AlertTriangle } from 'lucide-react' +import { accountsApi } from '@/api/accounts' +import { useAuthStore } from '@/store/authStore' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +interface Props { + accountName: string + onClose: () => void +} + +export function LeaveAccountModal({ accountName, onClose }: Props) { + const fetchUser = useAuthStore((s) => s.fetchUser) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleLeave = async () => { + setIsSubmitting(true) + try { + await accountsApi.leaveAccount() + toast.success('You have left the account') + await fetchUser() + onClose() + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + toast.error(axiosErr.response?.data?.detail ?? 'Failed to leave account') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+ +

Leave Account

+
+

+ Are you sure you want to leave {accountName}? + A new personal account will be created for you. +

+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/account/TransferOwnershipModal.tsx b/frontend/src/components/account/TransferOwnershipModal.tsx new file mode 100644 index 00000000..00e414f8 --- /dev/null +++ b/frontend/src/components/account/TransferOwnershipModal.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { Loader2, AlertTriangle } from 'lucide-react' +import { accountsApi } from '@/api/accounts' +import { useAuthStore } from '@/store/authStore' +import type { AccountMember } from '@/types' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +interface Props { + members: AccountMember[] + onClose: () => void + onTransferred: () => void +} + +export function TransferOwnershipModal({ members, onClose, onTransferred }: Props) { + const user = useAuthStore((s) => s.user) + const nonOwnerMembers = members.filter((m) => m.id !== user?.id) + const [targetUserId, setTargetUserId] = useState(nonOwnerMembers[0]?.id ?? '') + const [password, setPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!targetUserId || !password) return + + setIsSubmitting(true) + setError(null) + try { + await accountsApi.transferOwnership(password, targetUserId) + toast.success('Ownership transferred') + onTransferred() + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setError(axiosErr.response?.data?.detail ?? 'Transfer failed') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+ +

Transfer Ownership

+
+

+ This will make the selected member the new account owner. You will become an engineer. +

+ + {nonOwnerMembers.length === 0 ? ( +

No other members to transfer to.

+ ) : ( +
+
+ + +
+
+ + setPassword(e.target.value)} + required + className={cn( + 'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2', + 'text-foreground focus:border-primary focus:outline-none' + )} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/assistant/ChatMessage.tsx b/frontend/src/components/assistant/ChatMessage.tsx index 4cd6e1c7..21f04e48 100644 --- a/frontend/src/components/assistant/ChatMessage.tsx +++ b/frontend/src/components/assistant/ChatMessage.tsx @@ -1,4 +1,5 @@ import { Sparkles, User } from 'lucide-react' +import { MarkdownContent } from '@/components/ui/MarkdownContent' import { SuggestedFlowCard } from './SuggestedFlowCard' import type { SuggestedFlow } from '@/types/copilot' @@ -31,7 +32,7 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps) : 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]' }`} > -
{content}
+ {/* Suggested flows (assistant only) */} diff --git a/frontend/src/components/copilot/CopilotPanel.tsx b/frontend/src/components/copilot/CopilotPanel.tsx index 51067b8d..04095956 100644 --- a/frontend/src/components/copilot/CopilotPanel.tsx +++ b/frontend/src/components/copilot/CopilotPanel.tsx @@ -1,5 +1,6 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { X, Send, Sparkles, Loader2 } from 'lucide-react' +import { MarkdownContent } from '@/components/ui/MarkdownContent' import { copilotApi } from '@/api/copilot' import { SuggestedFlowCard } from '@/components/assistant/SuggestedFlowCard' import type { CopilotMessage, SuggestedFlow } from '@/types/copilot' @@ -20,21 +21,9 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId const [loading, setLoading] = useState(false) const [initializing, setInitializing] = useState(false) const messagesEndRef = useRef(null) + const inputRef = useRef(null) - // Start conversation when panel opens - useEffect(() => { - if (isOpen && !conversationId && !initializing) { - startConversation() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]) - - // Auto-scroll to bottom - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) - - const startConversation = async () => { + const startConversation = useCallback(async () => { setInitializing(true) try { const response = await copilotApi.startConversation({ @@ -49,7 +38,19 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId } finally { setInitializing(false) } - } + }, [treeId, sessionId, currentNodeId]) + + // Start conversation when panel opens or treeId changes + useEffect(() => { + if (isOpen && !conversationId && !initializing) { + startConversation() + } + }, [isOpen, treeId, startConversation, conversationId, initializing]) + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) const handleSend = async () => { if (!input.trim() || !conversationId || loading) return @@ -72,6 +73,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId setMessages(prev => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }]) } finally { setLoading(false) + requestAnimationFrame(() => inputRef.current?.focus()) } } @@ -123,7 +125,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId : 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]' }`} > -
{msg.content}
+ ))} @@ -154,6 +156,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId