Files
resolutionflow/backend/app/api/endpoints/accounts.py
chihlasm 4d2c4930fd feat: Slate & Ice Modern aesthetic redesign (#94)
* chore: update Google Fonts to Bricolage Grotesque, IBM Plex Sans, JetBrains Mono

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update Tailwind config to Slate & Ice theme colors and fonts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update CSS variables and glass-card utilities for Slate & Ice theme

- Replace all color variables with Slate & Ice palette
- Add glass system vars (--glass-bg, --glass-blur, --shadow-float)
- Replace legacy glass-card with new variable-driven glass classes
- Add breatheGlow, bellWobble, slideDown, fadeInRight keyframes
- Update font references to IBM Plex Sans and Bricolage Grotesque

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: recolor BrandLogo to cyan gradient, split BrandWordmark for gradient Flow text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update TopBar with glassmorphism backdrop and cyan accent styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update Sidebar with glassmorphism backdrop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ambient atmosphere gradient orbs behind app shell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update QuickStats and SessionsPanel with glass-card styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add WeeklyCalendar, QuickActions, OpenSessions, RecentActivity dashboard components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: redesign dashboard layout with calendar, open sessions, and glass-card panels

New layout: greeting → calendar+actions → sessions+stats → activity
Replaces old QuickStats and SessionsPanel with new dashboard components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace remaining purple hex references with ice-cyan accent

Sweep of hardcoded purple hex values (#818cf8, #6366f1) replaced with
new cyan accent (#06b6d4) in QuickActions, RecentActivity, QuickLaunch,
and SVG brand assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md branding and design system for Slate & Ice Modern

Updated Last Updated date, branding section (fonts, colors, glass
utilities, atmosphere orbs), component styling rules, and Design System
section to reflect the new ice-cyan glassmorphism theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Slate & Ice Modern design doc and implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: redesign login page with Slate & Ice Modern design system

Apply glassmorphism styling, atmosphere orbs, branded wordmark, and
consistent design tokens to match the updated app shell aesthetic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: raise TopBar z-index so profile dropdown renders above main content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI assistant with in-session copilot and standalone chat with RAG

Implements three-phase AI assistant feature:
- Phase 0: RAG infrastructure with pgvector embeddings, Voyage AI integration,
  tree chunking service, and semantic search over team's flow library
- Phase 1: In-session copilot panel during flow navigation with contextual
  AI help, current step awareness, and suggested related flows
- Phase 2: Standalone AI chat page with persistent conversation history,
  pin/delete, and configurable retention policies (account-level)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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>

* fix: prevent stale chunk errors after deployments

- Set Cache-Control no-cache on index.html in nginx so browsers always
  fetch fresh chunk references after a deploy
- Auto-reload on chunk load failures (stale deploy detection) with
  loop prevention via sessionStorage
- Show friendly "App Updated" message if auto-reload doesn't resolve it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add email verification toggle to admin settings

Adds platform-level toggle to enable/disable email verification.
When disabled, the verification banner is hidden and the send
endpoint returns 403.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:44:25 -05:00

468 lines
14 KiB
Python

from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
from uuid import UUID
import secrets
import string
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, 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"])
@router.get("/me", response_model=AccountResponse)
async def get_my_account(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Get current user's account."""
result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account not found"
)
return account
@router.get("/me/subscription", response_model=SubscriptionDetails)
async def get_my_subscription(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Get current user's subscription details including limits and usage."""
sub = await get_account_subscription(current_user.account_id, db)
if not sub:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No subscription found"
)
limits = await get_plan_limits(sub.plan, db)
if not limits:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Plan limits not configured"
)
usage = await get_account_usage(current_user.account_id, db)
return SubscriptionDetails(
subscription=SubscriptionResponse.model_validate(sub),
limits=PlanLimitsResponse.model_validate(limits),
usage=UsageResponse(**usage),
)
@router.get("/me/members", response_model=list[UserResponse])
async def get_my_members(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Get members of current user's account."""
result = await db.execute(
select(User).where(User.account_id == current_user.account_id)
.order_by(User.created_at)
)
return result.scalars().all()
@router.patch("/me", response_model=AccountResponse)
async def update_my_account(
data: AccountUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Update account settings (owner only)."""
result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account not found"
)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(account, field, value)
await db.commit()
await db.refresh(account)
return account
@router.patch("/me/members/{user_id}/role", response_model=UserResponse)
async def update_member_role(
user_id: UUID,
data: AccountRoleUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Change a member's role within the account (owner only)."""
result = await db.execute(
select(User).where(
User.id == user_id,
User.account_id == current_user.account_id
)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in your account"
)
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot change your own role"
)
user.account_role = data.account_role
await db.commit()
await db.refresh(user)
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,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Remove a member from the account (owner only).
The removed user gets a new personal account.
"""
result = await db.execute(
select(User).where(
User.id == user_id,
User.account_id == current_user.account_id
)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in your account"
)
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove yourself from your own account"
)
# Create a personal account for the removed user
chars = string.ascii_uppercase + string.digits
display_code = ''.join(secrets.choice(chars) for _ in range(8))
new_account = Account(
name=f"{user.name}'s Account",
display_code=display_code,
owner_id=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)
user.account_id = new_account.id
user.account_role = "owner"
await db.commit()
return None
@router.post("/me/invites", response_model=AccountInviteResponse, status_code=status.HTTP_201_CREATED)
async def create_invite(
data: AccountInviteCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Create an invite to join this account (owner only)."""
code = secrets.token_urlsafe(16)
expires_at = None
if data.expires_in_days:
expires_at = datetime.now(timezone.utc) + timedelta(days=data.expires_in_days)
invite = AccountInvite(
account_id=current_user.account_id,
invited_by_id=current_user.id,
email=data.email,
code=code,
role=data.role,
expires_at=expires_at,
)
db.add(invite)
await db.commit()
await db.refresh(invite)
return invite
@router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse)
async def resend_invite(
invite_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Revoke an existing account invite and create a new one, then email it."""
result = await db.execute(
select(AccountInvite).where(
AccountInvite.id == invite_id,
AccountInvite.account_id == current_user.account_id,
)
)
old_invite = result.scalar_one_or_none()
if not old_invite:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invite not found"
)
if old_invite.is_used:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot resend a used invite"
)
# Recalculate expiration from now if the old one had an expiration
new_expires_at = None
if old_invite.expires_at and old_invite.created_at:
original_duration = old_invite.expires_at - old_invite.created_at
new_expires_at = datetime.now(timezone.utc) + original_duration
elif old_invite.expires_at:
new_expires_at = old_invite.expires_at
# Capture properties before deleting
email = old_invite.email
role = old_invite.role
await db.delete(old_invite)
await db.flush()
# Create new invite
code = secrets.token_urlsafe(16)
new_invite = AccountInvite(
account_id=current_user.account_id,
invited_by_id=current_user.id,
email=email,
code=code,
role=role,
expires_at=new_expires_at,
)
db.add(new_invite)
await db.flush()
# Get account name for email
account_result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = account_result.scalar_one()
email_sent = await EmailService.send_account_invite_email(
to_email=email,
code=code,
account_name=account.name,
role=role,
)
await log_audit(
db, current_user.id, "account_invite.resend", "account_invite", new_invite.id,
{
"email": email,
"role": role,
"email_sent": email_sent,
},
)
await db.commit()
await db.refresh(new_invite)
return new_invite
@router.get("/me/invites", response_model=list[AccountInviteResponse])
async def list_invites(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""List invites for this account (owner only)."""
result = await db.execute(
select(AccountInvite)
.where(AccountInvite.account_id == current_user.account_id)
.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"}