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

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

View File

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

View File

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

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,

View File

@@ -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(
</body></html>"""
def _render_email_verification_html(verification_url: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
Please verify your email address by clicking the button below. This link expires in 24 hours.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{verification_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Verify Email
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
If you didn't create an account, you can safely ignore this email.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_feedback_confirmation_html(
feedback_type: str,
message_preview: str,

View File

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

View File

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

View File

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

View File

@@ -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)$")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
name: resolutionflow
services:
db:
image: postgres:16-alpine
image: pgvector/pgvector:pg16
container_name: resolutionflow_postgres
environment:
POSTGRES_USER: postgres

View File

@@ -48,6 +48,22 @@ export const accountsApi = {
const response = await apiClient.post<AccountInvite>(`/accounts/me/invites/${inviteId}/resend`)
return response.data
},
async transferOwnership(currentPassword: string, targetUserId: string): Promise<Account> {
const response = await apiClient.post<Account>('/accounts/me/transfer-ownership', {
current_password: currentPassword,
target_user_id: targetUserId,
})
return response.data
},
async leaveAccount(): Promise<void> {
await apiClient.post('/accounts/me/leave')
},
async deleteAccount(currentPassword: string): Promise<void> {
await apiClient.delete('/accounts/me', { data: { current_password: currentPassword } })
},
}
export default accountsApi

View File

@@ -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<User> {
@@ -53,6 +53,19 @@ export const authApi = {
new_password: newPassword,
})
},
async updateProfile(data: UserUpdate): Promise<User> {
const response = await apiClient.patch<User>('/auth/me', data)
return response.data
},
async sendVerificationEmail(): Promise<void> {
await apiClient.post('/auth/email/send-verification')
},
async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token })
},
}
export default authApi

View File

@@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="glass-card-static w-full max-w-md p-6">
<div className="flex items-center gap-2 text-rose-500 mb-4">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold font-heading text-foreground">Delete Account</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
This action is <strong className="text-rose-400">permanent</strong>. Your account, data,
and all associated flows will be permanently deleted.
</p>
<form onSubmit={handleDelete} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground">Confirm Password</label>
<input
type="password"
value={password}
onChange={(e) => 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'
)}
/>
</div>
{error && <p className="text-sm text-rose-500">{error}</p>}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
)}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !password}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-semibold',
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
)}
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete Forever'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="glass-card-static w-full max-w-md p-6">
<div className="flex items-center gap-2 text-amber-400 mb-4">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold font-heading text-foreground">Leave Account</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
Are you sure you want to leave <strong className="text-foreground">{accountName}</strong>?
A new personal account will be created for you.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
)}
>
Cancel
</button>
<button
onClick={handleLeave}
disabled={isSubmitting}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-semibold',
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
)}
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Leave Account'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="glass-card-static w-full max-w-md p-6">
<div className="flex items-center gap-2 text-amber-400 mb-4">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold font-heading text-foreground">Transfer Ownership</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
This will make the selected member the new account owner. You will become an engineer.
</p>
{nonOwnerMembers.length === 0 ? (
<p className="text-sm text-muted-foreground">No other members to transfer to.</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground">New Owner</label>
<select
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
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'
)}
>
{nonOwnerMembers.map((m) => (
<option key={m.id} value={m.id}>{m.name} ({m.email})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground">Your Password</label>
<input
type="password"
value={password}
onChange={(e) => 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'
)}
/>
</div>
{error && <p className="text-sm text-rose-500">{error}</p>}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
)}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !password}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-semibold',
'bg-amber-500 text-[#101114] hover:bg-amber-400',
'disabled:opacity-50'
)}
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Transfer'}
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -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)]'
}`}
>
<div className="whitespace-pre-wrap">{content}</div>
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
</div>
{/* Suggested flows (assistant only) */}

View File

@@ -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<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(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)]'
}`}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
</div>
</div>
))}
@@ -154,6 +156,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -0,0 +1,34 @@
import { Link } from 'react-router-dom'
import type { Guide } from '@/data/guides'
interface GuideCardProps {
guide: Guide
}
export function GuideCard({ guide }: GuideCardProps) {
const Icon = guide.icon
return (
<Link
to={`/guides/${guide.slug}`}
className="glass-card block rounded-2xl p-5 transition-all"
>
<div className="flex items-start gap-3.5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Icon size={20} className="text-primary" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-heading font-semibold text-foreground mb-1">
{guide.title}
</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
{guide.summary}
</p>
<span className="mt-2 inline-block font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
{guide.sections.length} {guide.sections.length === 1 ? 'section' : 'sections'}
</span>
</div>
</div>
</Link>
)
}

View File

@@ -0,0 +1,49 @@
import { Lightbulb } from 'lucide-react'
import type { GuideSection as GuideSectionType } from '@/data/guides'
interface GuideSectionProps {
section: GuideSectionType
index: number
}
export function GuideSection({ section, index }: GuideSectionProps) {
return (
<div className="mb-8">
<h3 className="text-base font-heading font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-bold text-primary">
{index + 1}
</span>
{section.title}
</h3>
<ol className="space-y-3 pl-8">
{section.steps.map((step, i) => (
<li key={i} className="relative">
<span className="absolute -left-6 top-0.5 font-label text-[0.625rem] text-muted-foreground">
{i + 1}.
</span>
<p
className="text-sm text-foreground leading-relaxed"
dangerouslySetInnerHTML={{
__html: step.instruction
.replace(/\*\*(.*?)\*\*/g, '<strong class="text-foreground font-semibold">$1</strong>')
}}
/>
{step.detail && (
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{step.detail}
</p>
)}
{step.tip && (
<div className="mt-2 flex items-start gap-2 rounded-lg bg-primary/5 border-l-2 border-primary px-3 py-2">
<Lightbulb size={14} className="text-primary shrink-0 mt-0.5" />
<p className="text-xs text-muted-foreground leading-relaxed">
<span className="font-semibold text-foreground">Tip:</span> {step.tip}
</p>
</div>
)}
</li>
))}
</ol>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { TopBar } from './TopBar'
import { Sidebar } from './Sidebar'
import { EmailVerificationBanner } from './EmailVerificationBanner'
import { cn } from '@/lib/utils'
export function AppLayout() {
@@ -183,6 +184,7 @@ export function AppLayout() {
{/* Main Content */}
<main className="main-content overflow-y-auto">
<EmailVerificationBanner />
<Outlet />
</main>
</div>

View File

@@ -0,0 +1,50 @@
import { useState } from 'react'
import { AlertTriangle, X, Loader2 } from 'lucide-react'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
export function EmailVerificationBanner() {
const user = useAuthStore((s) => s.user)
const [dismissed, setDismissed] = useState(false)
const [isSending, setIsSending] = useState(false)
if (!user || user.email_verified_at || dismissed) return null
const handleResend = async () => {
setIsSending(true)
try {
await authApi.sendVerificationEmail()
toast.success('Verification email sent')
} catch {
toast.error('Failed to send verification email')
} finally {
setIsSending(false)
}
}
return (
<div className="flex items-center gap-3 border-b border-amber-400/20 bg-amber-400/5 px-4 py-2 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-400" />
<span className="text-amber-200">
Your email is not verified.
</span>
<button
onClick={handleResend}
disabled={isSending}
className={cn(
'text-amber-400 underline hover:text-amber-300 disabled:opacity-50'
)}
>
{isSending ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Resend verification email'}
</button>
<button
onClick={() => setDismissed(true)}
className="ml-auto text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles, BotMessageSquare } from 'lucide-react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles, BotMessageSquare, BookOpen } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
@@ -85,6 +85,7 @@ export function Sidebar() {
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
<NavItem href="/guides" icon={BookOpen} label="User Guides" collapsed />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
</div>
</>
@@ -134,6 +135,7 @@ export function Sidebar() {
>
{!sidebarCollapsed && (
<>
<NavItem href="/guides" icon={BookOpen} label="User Guides" />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" />
<NavItem href="/account" icon={Settings} label="Account" />
</>

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Search, Zap, LogOut, Shield, Settings } from 'lucide-react'
import { Search, Zap, LogOut, Shield, Settings, HelpCircle } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { BrandLogo } from '@/components/common/BrandLogo'
@@ -105,6 +105,13 @@ export function TopBar() {
>
<Zap size={18} />
</button>
<Link
to="/guides"
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
title="User Guides"
>
<HelpCircle size={18} />
</Link>
<NotificationsPanel />
{/* User avatar & menu */}

495
frontend/src/data/guides.ts Normal file
View File

@@ -0,0 +1,495 @@
import type { LucideIcon } from 'lucide-react'
import {
Rocket,
Box,
GitBranch,
ListChecks,
Play,
Clock,
Share2,
Sparkles,
BotMessageSquare,
Bookmark,
Wrench,
Settings,
BarChart3,
} from 'lucide-react'
export interface GuideStep {
instruction: string
detail?: string
tip?: string
}
export interface GuideSection {
title: string
steps: GuideStep[]
}
export interface Guide {
slug: string
title: string
icon: LucideIcon
summary: string
sections: GuideSection[]
}
export const guides: Guide[] = [
{
slug: 'getting-started',
title: 'Getting Started',
icon: Rocket,
summary: 'Account setup, first login, and navigating the app.',
sections: [
{
title: 'Logging In',
steps: [
{ instruction: 'Go to the ResolutionFlow login page and enter your email and password.' },
{ instruction: 'Click **Sign In** to access your dashboard.' },
{ instruction: 'If you forgot your password, click **Forgot password?** on the login page and follow the email instructions.', tip: 'Check your spam folder if you don\'t receive the reset email within a few minutes.' },
],
},
{
title: 'Navigating the App',
steps: [
{ instruction: 'The **sidebar** on the left contains all main navigation links: Dashboard, All Flows, Flow Editor, Sessions, Exports, and more.' },
{ instruction: 'The **top bar** has a search bar (Ctrl+K / Cmd+K) to quickly find flows, sessions, and tags.' },
{ instruction: 'Click the **Quick Launch** (lightning bolt icon) in the top bar to start a flow without navigating to it first.' },
{ instruction: 'Your **user avatar** in the top-right opens a menu for Account settings, Admin Panel (if applicable), and Logout.' },
],
},
{
title: 'Understanding the Dashboard',
steps: [
{ instruction: 'The Dashboard shows your active sessions, recent flows, and quick stats at a glance.' },
{ instruction: 'Click any active session card to resume where you left off.' },
{ instruction: 'Use the **Pinned Flows** section at the top of the sidebar for quick access to your most-used flows.' },
],
},
],
},
{
slug: 'creating-flows',
title: 'Creating Flows',
icon: Box,
summary: 'Create troubleshooting, procedural, and maintenance flows.',
sections: [
{
title: 'Creating a Troubleshooting Flow',
steps: [
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
{ instruction: 'Select **Troubleshooting** as the flow type.' },
{ instruction: 'Enter a name and optional description for your flow.' },
{ instruction: 'Click **Create** to open the canvas editor where you can build your decision tree.', tip: 'Choose a descriptive name like "DNS Resolution Failure" so your team can find it easily.' },
],
},
{
title: 'Creating a Procedural Flow (Project)',
steps: [
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
{ instruction: 'Select **Procedural** as the flow type.' },
{ instruction: 'Enter a name and description.' },
{ instruction: 'Click **Create** to open the procedural editor where you can add steps, intake forms, and checklists.' },
],
},
{
title: 'Creating a Maintenance Flow',
steps: [
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
{ instruction: 'Select **Maintenance** as the flow type.' },
{ instruction: 'Enter a name and description.' },
{ instruction: 'Click **Create**. Maintenance flows use the same step-based editor as procedural flows but support batch launches across multiple targets.' },
],
},
{
title: 'Managing Flow Properties',
steps: [
{ instruction: 'From the flow editor, click the flow name or settings area to update the name, description, category, and tags.' },
{ instruction: 'Assign a **category** to organize flows by topic (e.g., "Networking", "Active Directory").' },
{ instruction: 'Add **tags** for searchability (e.g., "DNS", "VPN", "Firewall").', tip: 'Tags are shared across your team. Use consistent naming so everyone can find relevant flows.' },
],
},
],
},
{
slug: 'tree-editor',
title: 'Tree Editor (Canvas)',
icon: GitBranch,
summary: 'Build decision trees with nodes, options, actions, and solutions.',
sections: [
{
title: 'Understanding the Canvas',
steps: [
{ instruction: 'The canvas editor displays your troubleshooting flow as a visual decision tree.' },
{ instruction: 'Each **node** represents a question, action, or solution in your troubleshooting path.' },
{ instruction: 'Nodes are connected by **options** — the answers or choices that lead to the next step.' },
{ instruction: 'Use the toolbar at the top to zoom, fit to screen, and access additional options.' },
],
},
{
title: 'Adding Nodes',
steps: [
{ instruction: 'Click the **+** button on any existing node to add a child node.' },
{ instruction: 'Choose the node type: **Question** (asks the engineer something), **Action** (instructs them to do something), or **Solution** (the resolution).' },
{ instruction: 'Type the node content — this is what the engineer will see during navigation.' },
{ instruction: 'For Question nodes, add **options** (answers) that branch to different paths.', tip: 'Keep questions specific and actionable. "Is the DNS server responding to nslookup?" is better than "Check DNS".' },
],
},
{
title: 'Editing Nodes',
steps: [
{ instruction: 'Click any node on the canvas to select it and open the edit panel.' },
{ instruction: 'Update the node content, type, or options in the side panel.' },
{ instruction: 'To delete a node, select it and click the **Delete** button or press the Delete key.' },
{ instruction: 'Use **Undo** (Ctrl+Z) and **Redo** (Ctrl+Shift+Z) to revert changes.' },
],
},
{
title: 'Solution Nodes',
steps: [
{ instruction: 'Solution nodes are endpoints — they represent the resolution to the troubleshooting path.' },
{ instruction: 'Write clear, actionable solutions with specific commands or steps the engineer should follow.' },
{ instruction: 'You can have multiple solution nodes for different resolution paths.' },
],
},
],
},
{
slug: 'procedural-editor',
title: 'Procedural Flow Editor',
icon: ListChecks,
summary: 'Build step-by-step procedures with intake forms and checklists.',
sections: [
{
title: 'Adding Steps',
steps: [
{ instruction: 'In the procedural editor, click **Add Step** to add a new step to your flow.' },
{ instruction: 'Enter the step title and detailed instructions.' },
{ instruction: 'Steps execute in order from top to bottom. Drag steps to reorder them.' },
{ instruction: 'Use **Section Headers** to group related steps under labeled sections.', tip: 'Break long procedures into sections like "Preparation", "Execution", and "Verification".' },
],
},
{
title: 'Intake Forms',
steps: [
{ instruction: 'Intake forms collect information before the procedure begins (e.g., client name, server IP).' },
{ instruction: 'Click **Add Field** in the intake form section to add a form field.' },
{ instruction: 'Choose the field type: **Text**, **Textarea**, **Select** (dropdown), **Number**, **URL**, or **Checkbox**.' },
{ instruction: 'Mark fields as **Required** if they must be filled before proceeding.' },
{ instruction: 'Field values become **variables** you can reference in step instructions using the variable name.', tip: 'Use descriptive variable names like "client_name" or "server_ip" so they\'re easy to reference in steps.' },
],
},
{
title: 'Step Options',
steps: [
{ instruction: 'Expand **More Options** on any step to access additional settings.' },
{ instruction: 'Add a **URL** field to link to relevant documentation or tools.' },
{ instruction: 'Steps can include notes fields where engineers enter observations during execution.' },
],
},
],
},
{
slug: 'running-flows',
title: 'Running Flows',
icon: Play,
summary: 'Navigate troubleshooting flows and execute procedural procedures.',
sections: [
{
title: 'Running a Troubleshooting Flow',
steps: [
{ instruction: 'Go to **All Flows** in the sidebar and find the flow you want to run.' },
{ instruction: 'Click the flow card, then click **Start** to begin a new session.' },
{ instruction: 'Read each question and select the answer that matches your situation.' },
{ instruction: 'Follow the path until you reach a **Solution** node with the resolution steps.' },
{ instruction: 'Use the **Scratchpad** (notepad icon) to take notes during navigation.', tip: 'You can pin frequently-used flows in the sidebar for quick access.' },
],
},
{
title: 'Running a Procedural Flow',
steps: [
{ instruction: 'Navigate to the procedural flow and click **Start**.' },
{ instruction: 'Fill out the **Intake Form** with required information, then click **Begin**.' },
{ instruction: 'Work through each step in order. Mark steps as complete using the checkbox.' },
{ instruction: 'Add notes to individual steps as you work through them.' },
{ instruction: 'The progress bar at the top shows your completion percentage.' },
],
},
{
title: 'Using Flow Assist (AI Copilot)',
steps: [
{ instruction: 'While navigating any flow, click the **Flow Assist** button (sparkles icon) in the bottom-right corner.' },
{ instruction: 'Ask questions about the current step, like "What else could cause this?" or "How do I check this?"' },
{ instruction: 'The AI understands your current position in the flow and provides contextual answers.' },
{ instruction: 'If the AI finds related flows in your team\'s library, they appear as **Suggested Flows** cards you can click to open.' },
],
},
],
},
{
slug: 'sessions',
title: 'Sessions',
icon: Clock,
summary: 'Session history, resuming, notes, and scratchpad.',
sections: [
{
title: 'Viewing Session History',
steps: [
{ instruction: 'Click **Sessions** in the sidebar to see all your past and active sessions.' },
{ instruction: 'Sessions are listed newest first. Use the filters to show only active or completed sessions.' },
{ instruction: 'Click any session to view its full details including the path taken and notes.' },
],
},
{
title: 'Resuming a Session',
steps: [
{ instruction: 'Find the session in your session history or on the Dashboard.' },
{ instruction: 'Click the session, then click **Resume** to continue where you left off.' },
{ instruction: 'All previous decisions and notes are preserved.', tip: 'Active sessions also appear in the sidebar badge count for quick reference.' },
],
},
{
title: 'Session Notes & Scratchpad',
steps: [
{ instruction: 'During flow navigation, click the **Scratchpad** icon to open the note-taking panel.' },
{ instruction: 'Type free-form notes about your troubleshooting process.' },
{ instruction: 'Notes are saved automatically and included in session exports.' },
],
},
],
},
{
slug: 'sharing-exports',
title: 'Sharing & Exports',
icon: Share2,
summary: 'Share sessions and export documentation.',
sections: [
{
title: 'Sharing a Session',
steps: [
{ instruction: 'Open a completed session from the session detail page.' },
{ instruction: 'Click the **Share** button to open the sharing modal.' },
{ instruction: 'A unique share link is generated. Click **Copy Link** to copy it to your clipboard.' },
{ instruction: 'Share the link with team members or clients — they can view the session path and notes.' },
{ instruction: 'Manage active share links from the **Exports** page in the sidebar.', tip: 'Share links respect your account\'s public sharing settings. Account owners can enable or disable public shares.' },
],
},
{
title: 'Exporting Sessions',
steps: [
{ instruction: 'From a session detail page, click the **Export** button.' },
{ instruction: 'Choose the detail level: **Summary** (high-level overview), **Standard** (key decisions), or **Detailed** (full path with all notes).' },
{ instruction: 'Preview the export and edit it if needed before downloading.' },
{ instruction: 'Enable **Sensitive Data Redaction** to automatically mask passwords, IPs, and credentials in the export.', tip: 'Use the summary export for client-facing documentation and the detailed export for internal records.' },
],
},
{
title: 'Managing Shares',
steps: [
{ instruction: 'Click **Exports** in the sidebar to see all your shared sessions.' },
{ instruction: 'View how many times each share link has been accessed.' },
{ instruction: 'Revoke share links by clicking the delete icon next to any active share.' },
],
},
],
},
{
slug: 'ai-assistant',
title: 'AI Assistant',
icon: BotMessageSquare,
summary: 'Standalone AI chat for IT questions and flow recommendations.',
sections: [
{
title: 'Starting a Conversation',
steps: [
{ instruction: 'Click **AI Assistant** in the sidebar to open the chat page.' },
{ instruction: 'Click **Start a Conversation** or the **+ New Chat** button in the left panel.' },
{ instruction: 'Type your question in the message box and press Enter or click the send button.' },
{ instruction: 'The AI responds as a Senior Systems & Network Engineer with MSP expertise.' },
],
},
{
title: 'Managing Conversations',
steps: [
{ instruction: 'All conversations are listed in the left sidebar panel, newest first.' },
{ instruction: 'Click any conversation to switch to it and see the full message history.' },
{ instruction: '**Pin** important conversations by right-clicking or using the pin icon — pinned chats stay at the top.' },
{ instruction: 'Delete conversations you no longer need by clicking the trash icon.' },
],
},
{
title: 'Suggested Flows',
steps: [
{ instruction: 'When you ask a question, the AI searches your team\'s flow library for relevant matches.' },
{ instruction: 'If related flows are found, they appear as **Suggested Flow** cards below the AI response.' },
{ instruction: 'Click a suggested flow card to navigate directly to that flow.' },
],
},
],
},
{
slug: 'ai-copilot',
title: 'Flow Assist (AI Copilot)',
icon: Sparkles,
summary: 'In-session AI help while navigating flows.',
sections: [
{
title: 'Opening Flow Assist',
steps: [
{ instruction: 'While navigating any flow, look for the **Flow Assist** button (sparkles icon) in the bottom-right corner of the screen.' },
{ instruction: 'Click it to open the AI assistant panel on the right side.' },
{ instruction: 'The AI automatically knows which flow you\'re in and what step you\'re on.' },
],
},
{
title: 'Asking Questions',
steps: [
{ instruction: 'Type your question in the message box at the bottom of the panel.' },
{ instruction: 'Ask things like "What else could cause this?", "How do I run this command?", or "Explain this step in more detail."' },
{ instruction: 'The AI provides contextual answers based on your current position in the flow.' },
{ instruction: 'Your conversation persists throughout the session — you can refer back to earlier answers.', tip: 'Flow Assist is especially useful when you encounter an unfamiliar step or need additional troubleshooting guidance.' },
],
},
{
title: 'Suggested Flows',
steps: [
{ instruction: 'If your question relates to other flows in your team\'s library, the AI shows **Related Flows** cards.' },
{ instruction: 'Click a card to open that flow in a new tab.' },
],
},
],
},
{
slug: 'step-library',
title: 'Step Library',
icon: Bookmark,
summary: 'Reusable steps you can import into any procedural flow.',
sections: [
{
title: 'Browsing the Step Library',
steps: [
{ instruction: 'Click **Step Library** in the sidebar to view all saved reusable steps.' },
{ instruction: 'Steps are organized by category and can be searched by name or tags.' },
{ instruction: 'Click any step to view its full details and instructions.' },
],
},
{
title: 'Saving Steps to the Library',
steps: [
{ instruction: 'In the procedural flow editor, click the **Save to Library** option on any step.' },
{ instruction: 'Give the library step a name and optional category.' },
{ instruction: 'The step is now available for reuse across all your procedural and maintenance flows.' },
],
},
{
title: 'Importing Library Steps',
steps: [
{ instruction: 'In the procedural flow editor, click **Import from Library** when adding a new step.' },
{ instruction: 'Browse or search the step library for the step you want.' },
{ instruction: 'Click **Import** to add it to your flow. The imported step is a copy — editing it won\'t affect the library version.', tip: 'Use the step library for common procedures like "Verify backup status" or "Check DNS resolution" that appear across multiple flows.' },
],
},
],
},
{
slug: 'maintenance',
title: 'Maintenance Flows',
icon: Wrench,
summary: 'Batch launches, target lists, and scheduled maintenance.',
sections: [
{
title: 'Setting Up a Maintenance Flow',
steps: [
{ instruction: 'Create a new flow and select **Maintenance** as the type.' },
{ instruction: 'Build your steps in the procedural editor — these are the maintenance tasks to perform on each target.' },
{ instruction: 'The flow detail page shows maintenance-specific options including batch launches and scheduling.' },
],
},
{
title: 'Target Lists',
steps: [
{ instruction: 'Go to **Account** > **Target Lists** to manage your saved target lists.' },
{ instruction: 'Create a target list with the servers, workstations, or devices you maintain.' },
{ instruction: 'Target lists can be reused across multiple maintenance flows and batch launches.', tip: 'Organize target lists by client or site for easy batch launches (e.g., "Acme Corp - Domain Controllers").' },
],
},
{
title: 'Batch Launching',
steps: [
{ instruction: 'Open a maintenance flow and click **Launch Batch**.' },
{ instruction: 'Select a saved **Target List** or manually enter targets.' },
{ instruction: 'Click **Launch** to create a session for each target in the list.' },
{ instruction: 'All sessions are created immediately. Click into any target to begin executing the maintenance steps.' },
{ instruction: 'Track progress on the **Batch Status** page showing completion status across all targets.' },
],
},
{
title: 'Scheduling',
steps: [
{ instruction: 'On the maintenance flow detail page, click **Schedule** to set up recurring execution.' },
{ instruction: 'Choose a schedule (e.g., weekly, monthly) using the cron expression builder.' },
{ instruction: 'Select the target list to use for each scheduled run.' },
{ instruction: 'Scheduled batches are launched automatically at the configured time.' },
],
},
],
},
{
slug: 'account-settings',
title: 'Account Settings',
icon: Settings,
summary: 'Team management, categories, tags, and profile settings.',
sections: [
{
title: 'Profile Settings',
steps: [
{ instruction: 'Click your **avatar** in the top-right corner and select **Account**.' },
{ instruction: 'Click **Profile Settings** to update your display name, email, and password.' },
{ instruction: 'Changes take effect immediately after saving.' },
],
},
{
title: 'Team Categories',
steps: [
{ instruction: 'Go to **Account** and click **Team Categories** (account owner only).' },
{ instruction: 'Add categories to organize your team\'s flows (e.g., "Networking", "Security", "Cloud").' },
{ instruction: 'Assign colors to categories for visual distinction in the flow library.' },
{ instruction: 'Delete or rename categories as your team\'s needs evolve.' },
],
},
{
title: 'Chat Retention',
steps: [
{ instruction: 'Go to **Account** and click **Chat Retention** (account owner only).' },
{ instruction: 'Set the **retention period** (default: 90 days) — chats older than this are automatically deleted.' },
{ instruction: 'Set the **maximum conversation count** (default: 100) — oldest chats are deleted when the limit is exceeded.' },
{ instruction: 'Pinned chats are never automatically deleted.', tip: 'Pin important AI conversations to preserve them regardless of retention settings.' },
],
},
],
},
{
slug: 'analytics',
title: 'Analytics',
icon: BarChart3,
summary: 'Dashboard metrics, team usage, and personal stats.',
sections: [
{
title: 'Team Analytics',
steps: [
{ instruction: 'Click **Analytics** in the sidebar to view team-wide metrics.' },
{ instruction: 'See total flows, active sessions, completion rates, and usage trends.' },
{ instruction: 'Filter by date range to analyze specific periods.' },
],
},
{
title: 'Personal Analytics',
steps: [
{ instruction: 'From the Analytics page, click **My Stats** to see your individual metrics.' },
{ instruction: 'Track your session count, most-used flows, and average completion time.' },
{ instruction: 'Use personal analytics to identify areas where you spend the most troubleshooting time.' },
],
},
],
},
]

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles, Send, Loader2 } from 'lucide-react'
import { assistantChatApi } from '@/api/assistantChat'
import { toast } from '@/lib/toast'
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
import type { ChatListItem, ChatMessage as ChatMessageType } from '@/types/assistant-chat'
import type { ChatListItem, AssistantChatMessage as ChatMessageType } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
interface MessageWithMeta extends ChatMessageType {
@@ -17,6 +18,7 @@ export default function AssistantChatPage() {
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
// Load chat list
useEffect(() => {
@@ -57,7 +59,7 @@ export default function AssistantChatPage() {
setActiveChatId(chat.id)
setMessages([])
} catch {
// silently handle
toast.error('Failed to create chat')
}
}
@@ -70,7 +72,7 @@ export default function AssistantChatPage() {
setMessages([])
}
} catch {
// silently handle
toast.error('Failed to delete chat')
}
}
@@ -81,7 +83,7 @@ export default function AssistantChatPage() {
prev.map(c => c.id === chatId ? { ...c, pinned } : c)
)
} catch {
// silently handle
toast.error('Failed to update chat')
}
}
@@ -114,6 +116,7 @@ export default function AssistantChatPage() {
])
} finally {
setLoading(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
}
@@ -181,6 +184,7 @@ export default function AssistantChatPage() {
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-end gap-3 max-w-3xl mx-auto">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -0,0 +1,78 @@
import { useParams, Link } from 'react-router-dom'
import { ChevronRight, ArrowLeft } from 'lucide-react'
import { guides } from '@/data/guides'
import { GuideSection } from '@/components/guides/GuideSection'
export default function GuideDetailPage() {
const { slug } = useParams<{ slug: string }>()
const guide = guides.find(g => g.slug === slug)
if (!guide) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">Guide Not Found</h2>
<p className="text-sm text-muted-foreground mb-4">The guide you're looking for doesn't exist.</p>
<Link
to="/guides"
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2 hover:opacity-90 active:scale-[0.97] transition-all"
>
Back to Guides
</Link>
</div>
)
}
const Icon = guide.icon
return (
<div className="p-6 max-w-3xl mx-auto">
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground mb-6">
<Link to="/guides" className="hover:text-primary transition-colors">
User Guides
</Link>
<ChevronRight size={12} />
<span className="text-foreground">{guide.title}</span>
</nav>
{/* Header */}
<div className="glass-card-static rounded-2xl p-6 mb-6">
<div className="flex items-center gap-3 mb-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<Icon size={20} className="text-primary" />
</div>
<div>
<h1 className="text-xl font-heading font-bold text-foreground">{guide.title}</h1>
<p className="text-sm text-muted-foreground">{guide.summary}</p>
</div>
</div>
<div className="flex items-center gap-4 mt-4 pt-4 border-t" style={{ borderColor: 'var(--glass-border)' }}>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
{guide.sections.length} {guide.sections.length === 1 ? 'section' : 'sections'}
</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
{guide.sections.reduce((acc, s) => acc + s.steps.length, 0)} steps
</span>
</div>
</div>
{/* Sections */}
<div className="glass-card-static rounded-2xl p-6">
{guide.sections.map((section, i) => (
<GuideSection key={i} section={section} index={i} />
))}
</div>
{/* Back link */}
<div className="mt-6">
<Link
to="/guides"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-primary transition-colors"
>
<ArrowLeft size={14} />
Back to all guides
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { BookOpen } from 'lucide-react'
import { guides } from '@/data/guides'
import { GuideCard } from '@/components/guides/GuideCard'
export default function GuidesHubPage() {
return (
<div className="p-6 max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<BookOpen size={20} className="text-primary" />
</div>
<h1 className="text-2xl font-heading font-bold text-foreground">User Guides</h1>
</div>
<p className="text-sm text-muted-foreground ml-[52px]">
Learn how to use ResolutionFlow with step-by-step instructions for every feature.
</p>
</div>
{/* Guide cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{guides.map(guide => (
<GuideCard key={guide.slug} guide={guide} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
import { authApi } from '@/api/auth'
import { cn } from '@/lib/utils'
export function VerifyEmailPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [errorMessage, setErrorMessage] = useState('')
useEffect(() => {
if (!token) {
setStatus('error')
setErrorMessage('No verification token provided')
return
}
authApi.verifyEmail(token)
.then(() => setStatus('success'))
.catch((err) => {
setStatus('error')
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
setErrorMessage(detail ?? 'Verification failed')
})
}, [token])
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="glass-card-static w-full max-w-md p-8 text-center">
{status === 'loading' && (
<>
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-foreground">Verifying your email...</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-400" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Email Verified</h1>
<p className="mt-2 text-muted-foreground">Your email has been successfully verified.</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-[10px] bg-gradient-brand px-6 py-2 text-sm font-semibold text-[#101114]',
'shadow-lg shadow-primary/20 hover:opacity-90'
)}
>
Go to Dashboard
</Link>
</>
)}
{status === 'error' && (
<>
<XCircle className="mx-auto h-12 w-12 text-rose-500" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Verification Failed</h1>
<p className="mt-2 text-muted-foreground">{errorMessage}</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-6 py-2 text-sm font-medium text-foreground',
'hover:border-[rgba(255,255,255,0.12)]'
)}
>
Go to Dashboard
</Link>
</>
)}
</div>
</div>
)
}
export default VerifyEmailPage

View File

@@ -0,0 +1,184 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import type { UserUpdate } from '@/types'
const inputClass = cn(
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
)
export function ProfileSettingsPage() {
const user = useAuthStore((s) => s.user)
const fetchUser = useAuthStore((s) => s.fetchUser)
const [name, setName] = useState(user?.name ?? '')
const [email, setEmail] = useState(user?.email ?? '')
const [phone, setPhone] = useState(user?.phone ?? '')
const [jobTitle, setJobTitle] = useState(user?.job_title ?? '')
const [timezone, setTimezone] = useState(user?.timezone ?? 'UTC')
const [currentPassword, setCurrentPassword] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const emailChanged = email !== user?.email
const hasChanges =
emailChanged ||
name !== user?.name ||
phone !== (user?.phone ?? '') ||
jobTitle !== (user?.job_title ?? '') ||
timezone !== (user?.timezone ?? 'UTC')
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!hasChanges) return
setIsSaving(true)
setError(null)
try {
const payload: UserUpdate = {}
if (name !== user?.name) payload.name = name.trim()
if (emailChanged) {
payload.email = email.trim()
payload.current_password = currentPassword
}
if (phone !== (user?.phone ?? '')) payload.phone = phone.trim() || null
if (jobTitle !== (user?.job_title ?? '')) payload.job_title = jobTitle.trim() || null
if (timezone !== (user?.timezone ?? 'UTC')) payload.timezone = timezone
await authApi.updateProfile(payload)
await fetchUser()
setCurrentPassword('')
toast.success('Profile updated')
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setError(axiosErr.response?.data?.detail ?? 'Failed to update profile')
} finally {
setIsSaving(false)
}
}
return (
<div>
<div className="mb-8">
<div className="flex items-center gap-3">
<UserIcon className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Profile Settings</h1>
</div>
<p className="mt-2 text-muted-foreground">
Update your name, email, and personal details
</p>
</div>
<div className="max-w-xl">
<form onSubmit={handleSave} className="glass-card-static p-6 space-y-5">
{/* Name */}
<div>
<label htmlFor="profile-name" className="block text-sm font-medium text-foreground">Name</label>
<input id="profile-name" type="text" value={name} onChange={(e) => setName(e.target.value)} required className={inputClass} />
</div>
{/* Email */}
<div>
<label htmlFor="profile-email" className="block text-sm font-medium text-foreground">Email</label>
<input id="profile-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className={inputClass} />
</div>
{/* Password confirmation for email change */}
{emailChanged && (
<div>
<label htmlFor="profile-password" className="block text-sm font-medium text-foreground">Current Password</label>
<p className="text-xs text-muted-foreground">Required to change your email address</p>
<input id="profile-password" type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required className={inputClass} />
</div>
)}
{/* Phone */}
<div>
<label htmlFor="profile-phone" className="block text-sm font-medium text-foreground">Phone</label>
<input id="profile-phone" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Optional" className={inputClass} />
</div>
{/* Job Title */}
<div>
<label htmlFor="profile-job-title" className="block text-sm font-medium text-foreground">Job Title</label>
<input id="profile-job-title" type="text" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} placeholder="e.g. Network Engineer" className={inputClass} />
</div>
{/* Timezone */}
<div>
<label htmlFor="profile-timezone" className="block text-sm font-medium text-foreground">Timezone</label>
<select id="profile-timezone" value={timezone} onChange={(e) => setTimezone(e.target.value)} className={inputClass}>
{COMMON_TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-rose-500">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
{error}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={isSaving || !hasChanges}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114]',
'shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Save Changes
</button>
<Link
to="/change-password"
className={cn(
'inline-flex items-center rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)]'
)}
>
Change Password
</Link>
</div>
</form>
</div>
</div>
)
}
const COMMON_TIMEZONES = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'Pacific/Honolulu',
'America/Toronto',
'America/Vancouver',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Amsterdam',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Kolkata',
'Asia/Dubai',
'Australia/Sydney',
'Australia/Melbourne',
'Pacific/Auckland',
]
export default ProfileSettingsPage

View File

@@ -36,6 +36,8 @@ const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
@@ -283,6 +285,22 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'guides',
element: (
<Suspense fallback={<PageLoader />}>
<GuidesHubPage />
</Suspense>
),
},
{
path: 'guides/:slug',
element: (
<Suspense fallback={<PageLoader />}>
<GuideDetailPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin',

View File

@@ -3,14 +3,14 @@ import type { SuggestedFlow } from './copilot'
export interface AssistantChat {
id: string
title: string
messages: ChatMessage[]
messages: AssistantChatMessage[]
message_count: number
pinned: boolean
created_at: string
updated_at: string
}
export interface ChatMessage {
export interface AssistantChatMessage {
role: 'user' | 'assistant'
content: string
}

View File

@@ -11,7 +11,7 @@ export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInv
export * from './admin'
export * from './analytics'
export * from './copilot'
export type { AssistantChat, ChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
export type { AssistantChat, AssistantChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
// API response wrapper types
export interface PaginatedResponse<T> {

View File

@@ -12,6 +12,11 @@ export interface User {
account_role: 'owner' | 'engineer' | 'viewer' | null
created_at: string
last_login: string | null
phone: string | null
job_title: string | null
timezone: string
avatar_url: string | null
email_verified_at: string | null
}
export interface UserCreate {
@@ -30,4 +35,8 @@ export interface UserLogin {
export interface UserUpdate {
name?: string
email?: string
current_password?: string
phone?: string | null
job_title?: string | null
timezone?: string
}

View File

@@ -6,6 +6,12 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
watch: {
usePolling: true,
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),