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:
30
backend/alembic/versions/040_add_user_profile_fields.py
Normal file
30
backend/alembic/versions/040_add_user_profile_fields.py
Normal 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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
42
backend/app/models/email_verification_token.py
Normal file
42
backend/app/models/email_verification_token.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)$")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
109
backend/tests/test_account_lifecycle.py
Normal file
109
backend/tests/test_account_lifecycle.py
Normal 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
|
||||
63
backend/tests/test_account_transfer.py
Normal file
63
backend/tests/test_account_transfer.py
Normal 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
|
||||
90
backend/tests/test_auth_profile.py
Normal file
90
backend/tests/test_auth_profile.py
Normal 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
|
||||
57
backend/tests/test_email_verification.py
Normal file
57
backend/tests/test_email_verification.py
Normal 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
|
||||
@@ -1,7 +1,7 @@
|
||||
name: resolutionflow
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: resolutionflow_postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
92
frontend/src/components/account/DeleteAccountModal.tsx
Normal file
92
frontend/src/components/account/DeleteAccountModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/account/LeaveAccountModal.tsx
Normal file
67
frontend/src/components/account/LeaveAccountModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/account/TransferOwnershipModal.tsx
Normal file
115
frontend/src/components/account/TransferOwnershipModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
34
frontend/src/components/guides/GuideCard.tsx
Normal file
34
frontend/src/components/guides/GuideCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/guides/GuideSection.tsx
Normal file
49
frontend/src/components/guides/GuideSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
50
frontend/src/components/layout/EmailVerificationBanner.tsx
Normal file
50
frontend/src/components/layout/EmailVerificationBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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
495
frontend/src/data/guides.ts
Normal 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.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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}
|
||||
|
||||
78
frontend/src/pages/GuideDetailPage.tsx
Normal file
78
frontend/src/pages/GuideDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/GuidesHubPage.tsx
Normal file
29
frontend/src/pages/GuidesHubPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
frontend/src/pages/VerifyEmailPage.tsx
Normal file
75
frontend/src/pages/VerifyEmailPage.tsx
Normal 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
|
||||
184
frontend/src/pages/account/ProfileSettingsPage.tsx
Normal file
184
frontend/src/pages/account/ProfileSettingsPage.tsx
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user