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 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-04 19:18:06 -05:00
parent 1aa60dada2
commit 8d6accaf60
45 changed files with 2255 additions and 126 deletions

View File

@@ -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"}

View File

@@ -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],
)

View File

@@ -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"}

View File

@@ -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,