feat: Slate & Ice Modern aesthetic redesign #94

Merged
chihlasm merged 19 commits from feat/slate-ice-redesign into main 2026-03-05 01:44:25 +00:00
100 changed files with 8181 additions and 466 deletions

View File

@@ -1,6 +1,6 @@
# CLAUDE.md - Patherly / ResolutionFlow Project Context
> **Last Updated:** February 17, 2026
> **Last Updated:** March 3, 2026
---
@@ -19,28 +19,28 @@
| Repository / directory / database / Docker | `patherly` / `patherly_postgres` |
| Backend, frontend UI, production URLs | **ResolutionFlow** |
- **Design:** Dark-first with purple gradient accents (`#818cf8``#a78bfa`). NOT monochrome — use gradient for primary buttons, active nav indicators, stat highlights, and brand text.
- **Fonts:** Plus Jakarta Sans (`font-heading`, headings/titles), Inter (`font-sans`, body text), Outfit (`font-label`, labels/badges/counts) — loaded via Google Fonts
- **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon with gradient). Wordmark: "Resolution" in white + "Flow" in `text-gradient-brand`
- **Design:** Dark glassmorphism with ice-cyan accent gradient (`#06b6d4``#22d3ee`). Charcoal backgrounds, frosted-glass cards with `backdrop-filter: blur()`, orchestrated page-load animations, bold display typography. Design doc: [docs/plans/2026-03-03-aesthetic-redesign-design.md](docs/plans/2026-03-03-aesthetic-redesign-design.md)
- **Fonts:** Bricolage Grotesque (`font-heading`, headings/titles), IBM Plex Sans (`font-sans`, body text), JetBrains Mono (`font-label`, labels/badges/timestamps) — loaded via Google Fonts
- **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon with cyan gradient). Wordmark: "Resolution" in `text-foreground` + "Flow" in `text-gradient-brand`
- **Brand assets:** `brand-assets/` (source SVGs + brand-guide.html), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
- **CSS utilities:** `text-gradient-brand`, `bg-gradient-brand`, `bg-gradient-brand-hover` (defined in `tailwind.config.js` and `index.css`)
- **Layout:** App shell with persistent sidebar + top bar + main content (CSS Grid). See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **CSS utilities:** `text-gradient-brand`, `bg-gradient-brand`, `bg-gradient-brand-hover` (defined in `tailwind.config.js` and `index.css`). Glass utilities: `.glass-card` (interactive, `scale(1.02)` hover), `.glass-card-static` (no hover transform), `.active-glow` (breathing cyan shadow)
- **Layout:** App shell with persistent sidebar + top bar + main content (CSS Grid). Two fixed atmosphere orbs (cyan top-right, purple bottom-left) behind the shell for ambient glow. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Navigation:** Sidebar nav with type sub-items (All Flows → Troubleshooting / Projects / Maintenance). Pinned flows section for quick access. NO workspace switcher. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Maintenance flows are called "Maintenance" in the UI. `tree_type` column values unchanged in DB.
- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md)
**Component styling rules:**
- Primary buttons: `bg-gradient-brand` with `shadow-lg shadow-primary/20`, hover lifts with stronger shadow
- Secondary buttons: `bg-card` with `border-border`, hover brightens border
- Active nav items: `bg-primary/8` background + 3px left gradient accent bar
- Primary buttons: `bg-gradient-brand` (cyan `135deg`) with `shadow-lg shadow-primary/20`, hover `opacity-0.9`, active `scale(0.97)`
- Secondary buttons: `bg-[rgba(255,255,255,0.04)]` with `border-[rgba(255,255,255,0.06)]`, hover brightens border
- Active nav items: `bg-primary/10` background + 3px left cyan gradient accent bar
- Stat values: use `text-gradient-brand` for highlighted metrics
- Status colors: green (`text-green-500`) for success, amber (`text-amber-500`) for in-progress, red (`text-red-500`) for error/critical
- Status colors: emerald-400 (success), amber-400 (in-progress), rose-500 (error/critical)
- Category dots: 8px colored circles using the category color palette
- Tags/badges: `font-label` (Outfit), small rounded chips with `bg-card border-border`
- Cards: `bg-card border-border rounded-xl`, hover brightens border
- Section labels: `font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground`
- Tags/badges: `font-label` (JetBrains Mono), small rounded chips with `bg-card border-border`
- Cards: `.glass-card` (interactive) or `.glass-card-static` (non-interactive) — semi-transparent bg with `backdrop-filter: blur(16px)`, `border-radius: 16px`
- Section labels: `font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground`
When adding new pages/components: use "ResolutionFlow" branding, purple gradient accent theme, `bg-card` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows. Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, navigation, and component specs.
When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradient accent theme, `.glass-card` / `.glass-card-static` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows. Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, navigation, and component specs.
---
@@ -319,19 +319,20 @@ navigate(`/trees/${newTree.id}/edit`)
---
## Design System (Purple Gradient Accent)
## Design System (Slate & Ice Modern)
- **Theme:** Dark-only, purple gradient accents — NO monochrome `text-white/N` or `glass-card` patterns
- **Backgrounds:** `bg-background` (page), `bg-card` (cards/inputs/modals)
- **Cards:** `bg-card border border-border rounded-xl`
- **Buttons:** Primary: `bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90`. Secondary: `border border-border text-muted-foreground hover:bg-accent hover:text-foreground`
- **Inputs:** `border-border bg-card text-foreground placeholder:text-muted-foreground` + focus: `focus:border-primary focus:ring-1 focus:ring-primary/20`
- **Text:** `text-foreground``text-muted-foreground` (two levels only)
- **Borders:** `border-border` (single token, not opacity variants)
- **Hover states:** `hover:bg-accent` (backgrounds), `hover:text-foreground` (text)
- **Active/selected:** `bg-accent text-foreground` or `border-primary/30 bg-primary/10`
- **Functional color only:** emerald-400 (success), red-400 (error), yellow-400 (warning), blue-400 (info)
- **CSS variables:** Defined in `index.css` `:root``--primary`, `--card`, `--border`, `--accent`, `--muted-foreground`, etc.
- **Theme:** Dark glassmorphism with ice-cyan accent (`#06b6d4``#22d3ee`). Uses `.glass-card` / `.glass-card-static` for card surfaces
- **Backgrounds:** `bg-background` (`#101114` page), glass surfaces use `rgba(24, 26, 31, 0.55)` with `backdrop-filter: blur()`
- **Cards:** `.glass-card` (interactive, hover `scale(1.02)` + border/shadow upgrade) or `.glass-card-static` (no hover). Both have `border-radius: 16px`, semi-transparent bg, backdrop blur
- **Buttons:** Primary: `bg-gradient-brand text-[#101114] font-semibold rounded-[10px] hover:opacity-90 active:scale-[0.97]`. Secondary: `bg-[rgba(255,255,255,0.04)] border-[rgba(255,255,255,0.06)] text-foreground rounded-[10px]`
- **Inputs:** `border-border bg-card text-foreground placeholder:text-muted-foreground` + focus: `focus:border-[rgba(6,182,212,0.3)]`
- **Text:** `text-foreground` (`#f8fafc`)`text-muted-foreground` (`#8891a0`) → `text-[#5a6170]` (dim, for section labels/timestamps)
- **Borders:** `var(--glass-border)` (`rgba(255,255,255,0.06)`) default, `rgba(255,255,255,0.12)` on hover
- **Hover states:** Border brightens to `rgba(255,255,255,0.12)`, shadow upgrades to `--shadow-float-hover`
- **Active/selected:** `bg-primary/10 text-foreground` or cyan gradient accent bar
- **Functional colors:** emerald-400 (success), rose-500 (error), amber-400 (warning), blue-400 (info). Always pair with icons, not color alone.
- **CSS variables:** Glass system vars (`--glass-bg`, `--glass-border`, `--glass-blur`), shadow system (`--shadow-float`, `--shadow-float-hover`, `--shadow-cyan-glow`), easing (`--ease-out-smooth`) — all in `index.css` `:root`
- **Animations:** Orchestrated page-load sequence (slideDown, slideInLeft, fadeInUp cascade, fadeInRight). `breatheGlow` on first stat card. `bellWobble` on notification hover. See design doc for full spec.
---

View File

@@ -10,6 +10,10 @@ from alembic import context
# Import your models
from app.core.database import Base
from app.models import User, Team, Tree, Session, Attachment, InviteCode
from app.models.email_verification_token import EmailVerificationToken
from app.models.tree_embedding import TreeEmbedding
from app.models.copilot_conversation import CopilotConversation
from app.models.assistant_chat import AssistantChat
from app.core.config import settings
# this is the Alembic Config object

View File

@@ -0,0 +1,30 @@
"""Add user profile fields (phone, job_title, timezone, avatar_url, email_verified_at)
Revision ID: 040
Revises: fb1481317ff6
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "040"
down_revision = "e2d81e82ea5e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("phone", sa.String(50), nullable=True))
op.add_column("users", sa.Column("job_title", sa.String(255), nullable=True))
op.add_column("users", sa.Column("timezone", sa.String(100), nullable=False, server_default="UTC"))
op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True))
op.add_column("users", sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("users", "email_verified_at")
op.drop_column("users", "avatar_url")
op.drop_column("users", "timezone")
op.drop_column("users", "job_title")
op.drop_column("users", "phone")

View File

@@ -0,0 +1,30 @@
"""Add email_verification_tokens table
Revision ID: 041
Revises: 040
Create Date: 2026-03-03
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "041"
down_revision = "040"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"email_verification_tokens",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("token_hash", sa.String(64), unique=True, nullable=False, index=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("email_verification_tokens")

View File

@@ -0,0 +1,46 @@
"""Add pgvector extension and tree_embeddings table.
Revision ID: 042
Revises: 041
Create Date: 2026-03-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers
revision: str = "042"
down_revision: str = "041"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
"tree_embeddings",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("tree_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False),
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True),
sa.Column("chunk_type", sa.String(30), nullable=False),
sa.Column("node_type", sa.String(30), nullable=True),
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("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()")),
)
op.execute("ALTER TABLE tree_embeddings ADD COLUMN embedding vector(1024)")
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")

View File

@@ -0,0 +1,39 @@
"""Add copilot_conversations table.
Revision ID: 043
Revises: 042
Create Date: 2026-03-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "043"
down_revision: str = "042"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"copilot_conversations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("session_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True),
sa.Column("tree_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False),
sa.Column("messages", postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("current_node_id", sa.String(100), nullable=True),
sa.Column("message_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
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()")),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("copilot_conversations")

View File

@@ -0,0 +1,37 @@
"""Add assistant_chats table.
Revision ID: 044
Revises: 043
Create Date: 2026-03-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "044"
down_revision: str = "043"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"assistant_chats",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("title", sa.String(255), nullable=False, server_default="New Chat"),
sa.Column("messages", postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("message_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
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()")),
)
def downgrade() -> None:
op.drop_table("assistant_chats")

View File

@@ -0,0 +1,31 @@
"""Add chat retention settings to accounts.
Revision ID: 045
Revises: 044
Create Date: 2026-03-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "045"
down_revision: str = "044"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"accounts",
sa.Column("chat_retention_days", sa.Integer(), nullable=True, server_default=sa.text("90")),
)
op.add_column(
"accounts",
sa.Column("chat_retention_max_count", sa.Integer(), nullable=True, server_default=sa.text("100")),
)
def downgrade() -> None:
op.drop_column("accounts", "chat_retention_max_count")
op.drop_column("accounts", "chat_retention_days")

View File

@@ -7,17 +7,20 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.core.database import get_db
from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage
from app.core.audit import log_audit
from app.models.refresh_token import RefreshToken
from app.core.email import EmailService
from app.models.account import Account
from app.models.account_invite import AccountInvite
from app.models.subscription import Subscription
from app.models.user import User
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails
from app.schemas.user import UserResponse, AccountRoleUpdate
from app.core.security import verify_password
from app.api.deps import get_current_active_user, require_account_owner
router = APIRouter(prefix="/accounts", tags=["accounts"])
@@ -142,6 +145,58 @@ async def update_member_role(
return user
@router.post("/me/transfer-ownership", response_model=AccountResponse)
async def transfer_ownership(
data: TransferOwnershipRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Transfer account ownership to another member (owner only)."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
if data.target_user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot transfer ownership to yourself"
)
result = await db.execute(
select(User).where(
User.id == data.target_user_id,
User.account_id == current_user.account_id
)
)
target_user = result.scalar_one_or_none()
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in your account"
)
# Swap roles
current_user.account_role = "engineer"
target_user.account_role = "owner"
# Update account owner
result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = result.scalar_one()
account.owner_id = target_user.id
await log_audit(
db, current_user.id, "account.ownership_transfer", "account", account.id,
{"new_owner_id": str(target_user.id)}
)
await db.commit()
await db.refresh(account)
return account
@router.delete("/me/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
user_id: UUID,
@@ -318,3 +373,95 @@ async def list_invites(
.order_by(AccountInvite.created_at.desc())
)
return result.scalars().all()
@router.post("/me/leave")
async def leave_account(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Leave the current account (non-owners only). Creates a personal account."""
if current_user.account_role == "owner":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Account owners cannot leave. Transfer ownership first."
)
# Create a personal account (same pattern as remove_member)
chars = string.ascii_uppercase + string.digits
display_code = ''.join(secrets.choice(chars) for _ in range(8))
new_account = Account(
name=f"{current_user.name}'s Account",
display_code=display_code,
owner_id=current_user.id,
)
db.add(new_account)
await db.flush()
new_sub = Subscription(
account_id=new_account.id,
plan="free",
status="active",
)
db.add(new_sub)
old_account_id = current_user.account_id
current_user.account_id = new_account.id
current_user.account_role = "owner"
await log_audit(db, current_user.id, "account.leave", "account", old_account_id)
await db.commit()
return {"message": "You have left the account"}
class DeleteAccountRequest(BaseModel):
current_password: str
@router.delete("/me")
async def delete_account(
data: DeleteAccountRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)]
):
"""Delete the current account and soft-delete the user (owner only, no other members)."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
# Check no other members
result = await db.execute(
select(User).where(
User.account_id == current_user.account_id,
User.id != current_user.id,
User.deleted_at.is_(None)
)
)
if result.scalars().first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete account with other members. Remove them first."
)
# Soft-delete user
current_user.deleted_at = datetime.now(timezone.utc)
current_user.is_active = False
# Revoke all refresh tokens
rt_result = await db.execute(
select(RefreshToken).where(
RefreshToken.user_id == current_user.id,
RefreshToken.revoked_at.is_(None)
)
)
for rt in rt_result.scalars().all():
rt.revoked_at = datetime.now(timezone.utc)
await log_audit(db, current_user.id, "account.delete", "account", current_user.account_id)
await db.commit()
return {"message": "Account deleted"}

View File

@@ -0,0 +1,320 @@
"""Standalone AI assistant chat endpoints.
POST /assistant/chats — Create new chat
GET /assistant/chats — List chats (paginated, newest first)
GET /assistant/chats/{id} — Get chat with messages
POST /assistant/chats/{id}/messages — Send message
PATCH /assistant/chats/{id} — Update title, pin/unpin
DELETE /assistant/chats/{id} — Delete single chat
DELETE /assistant/chats — Bulk delete (older_than_days query param)
GET /assistant/retention — Get account retention settings
PATCH /assistant/retention — Update retention settings (owner only)
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.core.config import settings
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
from app.models.user import User
from app.models.account import Account
from app.models.assistant_chat import AssistantChat
from app.schemas.assistant_chat import (
ChatCreateRequest,
ChatMessageRequest,
ChatMessageResponse,
ChatListResponse,
ChatDetailResponse,
ChatUpdateRequest,
RetentionSettingsResponse,
RetentionSettingsUpdate,
)
from app.schemas.copilot import SuggestedFlow
from app.services import assistant_chat_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/assistant", tags=["assistant-chat"])
def _require_ai_enabled() -> None:
if not settings.ai_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
)
@router.post("/chats", response_model=ChatDetailResponse, status_code=201)
@limiter.limit("10/minute")
async def create_chat(
request: Request,
data: ChatCreateRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Create a new empty chat conversation."""
chat = await assistant_chat_service.create_chat(
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
)
await db.commit()
return ChatDetailResponse.model_validate(chat)
@router.get("/chats", response_model=list[ChatListResponse])
async def list_chats(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
):
"""List user's chat conversations (newest first, pinned on top)."""
offset = (page - 1) * size
result = await db.execute(
select(AssistantChat)
.where(AssistantChat.user_id == current_user.id)
.order_by(AssistantChat.pinned.desc(), AssistantChat.updated_at.desc())
.offset(offset)
.limit(size)
)
chats = result.scalars().all()
return [ChatListResponse.model_validate(c) for c in chats]
@router.get("/chats/{chat_id}", response_model=ChatDetailResponse)
async def get_chat(
chat_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a chat with full message history."""
result = await db.execute(
select(AssistantChat).where(
AssistantChat.id == chat_id,
AssistantChat.user_id == current_user.id,
)
)
chat = result.scalar_one_or_none()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
return ChatDetailResponse.model_validate(chat)
@router.post("/chats/{chat_id}/messages", response_model=ChatMessageResponse)
@limiter.limit("10/minute")
async def post_message(
request: Request,
chat_id: UUID,
data: ChatMessageRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Send a message and get AI response."""
_require_ai_enabled()
allowed, quota_status = await check_ai_quota(
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
billing_anchor=current_user.ai_billing_cycle_anchor_at,
is_super_admin=current_user.is_super_admin,
)
if not allowed:
reset_key = "daily_reset_at" if quota_status.get("deny_reason") == "daily" else "monthly_reset_at"
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"message": f"AI limit exceeded ({quota_status['deny_reason']})",
"reset_at": quota_status.get(reset_key),
"quota": quota_status,
},
)
plan = await get_user_plan(current_user.account_id, db)
try:
ai_content, suggested_flows, chat = await assistant_chat_service.send_message(
chat_id=chat_id,
user_id=current_user.id,
account_id=current_user.account_id,
message=data.message,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except Exception as e:
logger.exception("Assistant chat message failed: %s", e)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="assistant_message",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code=type(e).__name__,
extra_data={"assistant_chat_id": str(chat_id)},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="assistant_message",
tier=plan,
input_tokens=chat.total_input_tokens,
output_tokens=chat.total_output_tokens,
estimated_cost=(
chat.total_input_tokens * 1.0 / 1_000_000
+ chat.total_output_tokens * 5.0 / 1_000_000
),
succeeded=True,
counts_toward_quota=False,
error_code=None,
extra_data={"assistant_chat_id": str(chat_id)},
db=db,
)
await db.commit()
return ChatMessageResponse(
content=ai_content,
suggested_flows=[SuggestedFlow.model_validate(sf) for sf in suggested_flows],
)
@router.patch("/chats/{chat_id}", response_model=ChatDetailResponse)
async def update_chat(
chat_id: UUID,
data: ChatUpdateRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update chat title or pin/unpin."""
result = await db.execute(
select(AssistantChat).where(
AssistantChat.id == chat_id,
AssistantChat.user_id == current_user.id,
)
)
chat = result.scalar_one_or_none()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if data.title is not None:
chat.title = data.title
if data.pinned is not None:
chat.pinned = data.pinned
await db.commit()
return ChatDetailResponse.model_validate(chat)
@router.delete("/chats/{chat_id}", status_code=204)
async def delete_chat(
chat_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Delete a single chat."""
result = await db.execute(
select(AssistantChat).where(
AssistantChat.id == chat_id,
AssistantChat.user_id == current_user.id,
)
)
chat = result.scalar_one_or_none()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
await db.delete(chat)
await db.commit()
@router.delete("/chats", status_code=204)
async def bulk_delete_chats(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
older_than_days: int = Query(..., ge=1),
):
"""Bulk delete chats older than N days (skips pinned)."""
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
await db.execute(
delete(AssistantChat).where(
AssistantChat.user_id == current_user.id,
AssistantChat.pinned == False, # noqa: E712
AssistantChat.updated_at < cutoff,
)
)
await db.commit()
@router.get("/retention", response_model=RetentionSettingsResponse)
async def get_retention_settings(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get account chat retention settings."""
result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
return RetentionSettingsResponse(
chat_retention_days=account.chat_retention_days,
chat_retention_max_count=account.chat_retention_max_count,
)
@router.patch("/retention", response_model=RetentionSettingsResponse)
async def update_retention_settings(
data: RetentionSettingsUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update account chat retention settings (account owner only)."""
result = await db.execute(
select(Account).where(Account.id == current_user.account_id)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
if account.owner_id != current_user.id and not current_user.is_super_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the account owner can update retention settings",
)
if data.chat_retention_days is not None:
account.chat_retention_days = data.chat_retention_days
if data.chat_retention_max_count is not None:
account.chat_retention_max_count = data.chat_retention_max_count
await db.commit()
return RetentionSettingsResponse(
chat_retention_days=account.chat_retention_days,
chat_retention_max_count=account.chat_retention_max_count,
)

View File

@@ -7,6 +7,7 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.settings_manager import SettingsManager
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.core.security import (
@@ -15,6 +16,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 +26,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 +36,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 +354,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 +594,113 @@ async def reset_password(
await db.commit()
return {"message": "Password has been reset successfully"}
@router.get("/email/verification-status")
async def get_verification_status(
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Check if email verification is enabled on the platform."""
enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
return {"enabled": enabled}
@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."""
verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True)
if not verification_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email verification is currently disabled"
)
if current_user.email_verified_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already verified"
)
raw_token = create_email_verification_token(str(current_user.id))
payload = decode_token(raw_token)
if payload and payload.get("jti"):
token_record = EmailVerificationToken(
token_hash=hash_token(payload["jti"]),
user_id=current_user.id,
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
)
db.add(token_record)
await db.commit()
verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}"
await EmailService.send_email_verification_email(
to_email=current_user.email,
verification_url=verification_url,
)
return {"message": "Verification email sent"}
@router.post("/email/verify")
async def verify_email(
data: dict,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Verify an email using a token. Public endpoint."""
token = data.get("token")
if not token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token is required"
)
payload = decode_token(token)
if not payload or payload.get("type") != "email_verification":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification token"
)
jti = payload.get("jti")
user_id = payload.get("sub")
if not jti or not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid verification token"
)
result = await db.execute(
select(EmailVerificationToken).where(
EmailVerificationToken.token_hash == hash_token(jti)
)
)
token_record = result.scalar_one_or_none()
if not token_record or not token_record.is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verification token has already been used or has expired"
)
# Mark token as used
token_record.used_at = datetime.now(timezone.utc)
# Mark user email as verified
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid verification token"
)
user.email_verified_at = datetime.now(timezone.utc)
await log_audit(db, user.id, "auth.email_verified", "user", user.id)
await db.commit()
return {"message": "Email verified successfully"}

View File

@@ -0,0 +1,192 @@
"""In-session copilot endpoints.
Contextual AI assistant during flow navigation:
POST /copilot/conversations — Start conversation (requires tree_id)
POST /copilot/conversations/{id}/messages — Send message, get response + suggestions
GET /copilot/conversations/{id} — Get conversation history
"""
import logging
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
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.core.config import settings
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
from app.models.user import User
from app.schemas.copilot import (
CopilotStartRequest,
CopilotStartResponse,
CopilotMessageRequest,
CopilotMessageResponse,
CopilotConversationResponse,
SuggestedFlow,
)
from app.models.copilot_conversation import CopilotConversation
from app.services import copilot_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/copilot", tags=["copilot"])
def _require_ai_enabled() -> None:
if not settings.ai_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
)
@router.post("/conversations", response_model=CopilotStartResponse, status_code=201)
@limiter.limit("10/minute")
async def start_conversation(
request: Request,
data: CopilotStartRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Start a new copilot conversation for a flow."""
_require_ai_enabled()
allowed, quota_status = await check_ai_quota(
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
billing_anchor=current_user.ai_billing_cycle_anchor_at,
is_super_admin=current_user.is_super_admin,
)
if not allowed:
reset_key = "daily_reset_at" if quota_status.get("deny_reason") == "daily" else "monthly_reset_at"
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"message": f"AI limit exceeded ({quota_status['deny_reason']})",
"reset_at": quota_status.get(reset_key),
"quota": quota_status,
},
)
try:
conversation, greeting = await copilot_service.start_conversation(
user_id=current_user.id,
account_id=current_user.account_id,
tree_id=data.tree_id,
session_id=data.session_id,
current_node_id=data.current_node_id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except Exception as e:
logger.exception("Copilot conversation start failed: %s", e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await db.commit()
return CopilotStartResponse(
conversation_id=conversation.id,
greeting=greeting,
)
@router.post("/conversations/{conversation_id}/messages", response_model=CopilotMessageResponse)
@limiter.limit("10/minute")
async def post_message(
request: Request,
conversation_id: UUID,
data: CopilotMessageRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Send a message and get AI response with flow suggestions."""
_require_ai_enabled()
plan = await get_user_plan(current_user.account_id, db)
try:
ai_content, suggested_flows, conversation = await copilot_service.send_message(
conversation_id=conversation_id,
user_id=current_user.id,
message=data.message,
current_node_id=data.current_node_id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except Exception as e:
logger.exception("Copilot message failed: %s", e)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="copilot_message",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code=type(e).__name__,
extra_data={"copilot_conversation_id": str(conversation_id)},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="copilot_message",
tier=plan,
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,
extra_data={"copilot_conversation_id": str(conversation_id)},
db=db,
)
await db.commit()
return CopilotMessageResponse(
content=ai_content,
suggested_flows=[SuggestedFlow.model_validate(sf) for sf in suggested_flows],
)
@router.get("/conversations/{conversation_id}", response_model=CopilotConversationResponse)
async def get_conversation(
conversation_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get copilot conversation history."""
result = await db.execute(
select(CopilotConversation).where(
CopilotConversation.id == conversation_id,
CopilotConversation.user_id == current_user.id,
)
)
conversation = result.scalar_one_or_none()
if not conversation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
return CopilotConversationResponse.model_validate(conversation)

View File

@@ -1,3 +1,4 @@
import logging
from datetime import datetime, timezone
from typing import Annotated, Optional
from uuid import UUID
@@ -29,6 +30,7 @@ from app.core.audit import log_audit
from app.core.config import settings
from app.core.tree_validation import can_publish_tree
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
from app.services.rag_service import index_tree as rag_index_tree
router = APIRouter(prefix="/trees", tags=["trees"])
@@ -542,6 +544,13 @@ async def create_tree(
)
tree = result.scalar_one()
# Index tree for RAG (best-effort, don't fail the request)
try:
await rag_index_tree(tree.id, db)
await db.commit()
except Exception:
logging.getLogger(__name__).warning("RAG indexing failed for tree %s", tree.id)
return build_full_tree_response(tree)
@@ -725,6 +734,13 @@ async def update_tree(
)
tree = result.scalar_one()
# Re-index tree for RAG (best-effort)
try:
await rag_index_tree(tree.id, db)
await db.commit()
except Exception:
logging.getLogger(__name__).warning("RAG re-indexing failed for tree %s", tree.id)
return build_full_tree_response(tree)

View File

@@ -8,6 +8,8 @@ from app.api.endpoints import feedback
from app.api.endpoints import ai_builder
from app.api.endpoints import ai_fix
from app.api.endpoints import ai_chat
from app.api.endpoints import copilot
from app.api.endpoints import assistant_chat
api_router = APIRouter()
@@ -40,3 +42,5 @@ api_router.include_router(feedback.router)
api_router.include_router(ai_builder.router)
api_router.include_router(ai_fix.router)
api_router.include_router(ai_chat.router)
api_router.include_router(copilot.router)
api_router.include_router(assistant_chat.router)

View File

@@ -115,7 +115,7 @@ async def check_ai_quota(
select(func.count(AIUsage.id)).where(
AIUsage.user_id == user_id,
AIUsage.succeeded == True, # noqa: E712
AIUsage.generation_type.in_(["scaffold", "branch_detail", "chat_message", "chat_generate"]),
AIUsage.generation_type.in_(["scaffold", "branch_detail", "chat_message", "chat_generate", "copilot_message", "assistant_message"]),
AIUsage.created_at >= day_start,
)
) or 0

View File

@@ -84,6 +84,11 @@ class Settings(BaseSettings):
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001"
# Embedding / RAG
VOYAGE_API_KEY: Optional[str] = None
EMBEDDING_MODEL: str = "voyage-3.5"
EMBEDDING_DIMENSIONS: int = 1024
@property
def ai_enabled(self) -> bool:
"""Check if any AI provider is configured."""

View File

@@ -163,6 +163,39 @@ class EmailService:
logger.exception("Failed to send account invite email to %s", to_email)
return False
@staticmethod
async def send_email_verification_email(
to_email: str,
verification_url: str,
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = "Verify Your Email — ResolutionFlow"
html = _render_email_verification_html(verification_url=verification_url)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Verification email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send verification email to %s", to_email)
return False
@staticmethod
async def send_feedback_email(
to_email: str,
@@ -485,6 +518,38 @@ def _render_feedback_html(
</body></html>"""
def _render_email_verification_html(verification_url: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
Please verify your email address by clicking the button below. This link expires in 24 hours.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{verification_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Verify Email
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
If you didn't create an account, you can safely ignore this email.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_feedback_confirmation_html(
feedback_type: str,
message_preview: str,

View File

@@ -70,6 +70,19 @@ def create_password_reset_token(user_id: str) -> str:
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_email_verification_token(user_id: str) -> str:
"""Create a JWT email verification token (24-hour expiry, unique JTI)."""
jti = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(hours=24)
to_encode = {
"sub": user_id,
"type": "email_verification",
"jti": jti,
"exp": expire,
}
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def generate_temp_password(length: int = 16) -> str:
"""Generate a temporary password with guaranteed complexity.

View File

@@ -14,6 +14,7 @@ from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
from app.core.rate_limit import limiter
from app.api.router import api_router
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
from app.services.retention_cleanup import cleanup_expired_chats
from app.core.service_account import ensure_service_account
# Initialize logging configuration
@@ -122,6 +123,15 @@ async def lifespan(app: FastAPI):
replace_existing=True,
)
# Chat retention cleanup (daily)
scheduler.add_job(
cleanup_expired_chats,
trigger="interval",
hours=24,
id="cleanup_expired_chats",
replace_existing=True,
)
# Auto-seed trees in background on PR environments
seed_task = None
if settings.SEED_ON_DEPLOY:

View File

@@ -29,6 +29,9 @@ from .feedback import Feedback
from .ai_conversation import AIConversation
from .ai_usage import AIUsage
from .ai_chat_session import AIChatSession
from .tree_embedding import TreeEmbedding
from .copilot_conversation import CopilotConversation
from .assistant_chat import AssistantChat
__all__ = [
"User",
@@ -69,4 +72,7 @@ __all__ = [
"AIConversation",
"AIUsage",
"AIChatSession",
"TreeEmbedding",
"CopilotConversation",
"AssistantChat",
]

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
@@ -35,6 +35,14 @@ class Account(Base):
comment="Policy: engineers can create public shares. Only affects NEW shares (grandfathered)."
)
# Chat retention settings
chat_retention_days: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=90, server_default="90"
)
chat_retention_max_count: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, default=100, server_default="100"
)
# Relationships
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")

View File

@@ -0,0 +1,59 @@
"""Standalone AI assistant chat model.
Persistent conversation history for general IT questions with RAG context.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class AssistantChat(Base):
__tablename__ = "assistant_chats"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(
String(255), nullable=False, default="New Chat"
)
messages: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list
)
message_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
pinned: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,69 @@
"""Copilot conversation model for in-session AI assistant.
Tracks conversation state during flow navigation with contextual AI help.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class CopilotConversation(Base):
__tablename__ = "copilot_conversations"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="SET NULL"),
nullable=True,
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
)
messages: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list
)
current_node_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
message_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)

View File

@@ -0,0 +1,42 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class EmailVerificationToken(Base):
__tablename__ = "email_verification_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False,
index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
@property
def is_used(self) -> bool:
return self.used_at is not None
@property
def is_expired(self) -> bool:
return datetime.now(timezone.utc) > self.expires_at
@property
def is_valid(self) -> bool:
return not self.is_used and not self.is_expired

View File

@@ -0,0 +1,72 @@
"""Tree embedding storage for RAG-powered AI assistant.
Stores vector embeddings of tree content chunks for semantic search.
Each tree is split into multiple chunks (node, solution, tree_summary)
and embedded via Voyage AI for cosine similarity retrieval.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
# pgvector column type — imported at runtime to avoid import errors
# when pgvector is not installed locally
try:
from pgvector.sqlalchemy import Vector
except ImportError:
Vector = None
class TreeEmbedding(Base):
__tablename__ = "tree_embeddings"
__table_args__ = (
Index("ix_tree_embeddings_account_id", "account_id"),
Index("ix_tree_embeddings_tree_id", "tree_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=True,
)
chunk_type: Mapped[str] = mapped_column(
String(30),
nullable=False,
comment="node | solution | tree_summary",
)
node_type: Mapped[Optional[str]] = mapped_column(
String(30), nullable=True
)
node_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
embedding_model: Mapped[str] = mapped_column(
String(50), nullable=False, default="voyage-3.5"
)
# The embedding column is created via migration with vector(1024) type
# We store it as a generic column here and handle it in queries
meta: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -68,6 +68,15 @@ class User(Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Profile fields
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC")
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
email_verified_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
# AI billing cycle anchor (for quota reset calculation)
ai_billing_cycle_anchor_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True

View File

@@ -20,6 +20,11 @@ class AccountUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
class TransferOwnershipRequest(BaseModel):
current_password: str
target_user_id: UUID
class AccountInviteCreate(BaseModel):
email: str = Field(..., max_length=255)
role: str = Field("engineer", pattern="^(engineer|viewer)$")

View File

@@ -0,0 +1,59 @@
"""Pydantic schemas for standalone AI assistant chat."""
from typing import Optional, Any
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.copilot import SuggestedFlow
class ChatCreateRequest(BaseModel):
"""Empty body — creates a new blank conversation."""
pass
class ChatMessageRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=8000)
class ChatMessageResponse(BaseModel):
content: str
suggested_flows: list[SuggestedFlow] = []
class ChatListResponse(BaseModel):
id: UUID
title: str
message_count: int
pinned: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ChatDetailResponse(BaseModel):
id: UUID
title: str
messages: list[dict[str, Any]]
message_count: int
pinned: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ChatUpdateRequest(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
pinned: Optional[bool] = None
class RetentionSettingsResponse(BaseModel):
chat_retention_days: Optional[int]
chat_retention_max_count: Optional[int]
class RetentionSettingsUpdate(BaseModel):
chat_retention_days: Optional[int] = Field(None, ge=1, le=365)
chat_retention_max_count: Optional[int] = Field(None, ge=10, le=10000)

View File

@@ -0,0 +1,44 @@
"""Pydantic schemas for the in-session copilot."""
from typing import Optional, Any
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
class SuggestedFlow(BaseModel):
tree_id: UUID
tree_name: str
tree_type: str
relevance_snippet: str
class CopilotStartRequest(BaseModel):
tree_id: UUID
session_id: Optional[UUID] = None
current_node_id: Optional[str] = None
class CopilotStartResponse(BaseModel):
conversation_id: UUID
greeting: str
class CopilotMessageRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=4000)
current_node_id: Optional[str] = None
class CopilotMessageResponse(BaseModel):
content: str
suggested_flows: list[SuggestedFlow] = []
class CopilotConversationResponse(BaseModel):
id: UUID
tree_id: UUID
messages: list[dict[str, Any]]
current_node_id: Optional[str] = None
message_count: int
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -30,6 +30,10 @@ class UserCreate(UserBase):
class UserUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
email: Optional[EmailStr] = None
current_password: Optional[str] = Field(None, description="Required when changing email")
phone: Optional[str] = Field(None, max_length=50)
job_title: Optional[str] = Field(None, max_length=255)
timezone: Optional[str] = Field(None, max_length=100)
class UserLogin(BaseModel):
@@ -48,6 +52,11 @@ class UserResponse(UserBase):
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
phone: Optional[str] = None
job_title: Optional[str] = None
timezone: str = "UTC"
avatar_url: Optional[str] = None
email_verified_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,122 @@
"""Standalone AI assistant chat service with RAG context.
Provides persistent conversation history for general IT questions
with semantic search over the team's flow library.
"""
import logging
from typing import Optional, Any
from uuid import UUID
from sqlalchemy import select
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.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
logger = logging.getLogger(__name__)
ASSISTANT_SYSTEM_PROMPT = """You are a Senior Systems and Network Engineer with 15+ years of experience working in Managed Service Provider (MSP) environments. You specialize in:
- Windows Server, Active Directory, Group Policy, and Hybrid Identity (Entra ID)
- Networking (TCP/IP, DNS, DHCP, VPN, firewall troubleshooting, Cisco/Fortinet)
- Virtualization (VMware, Hyper-V) and cloud platforms (Azure, AWS, M365)
- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya)
- PowerShell scripting and automation
When answering:
- Be direct and actionable — MSP engineers need fast, practical answers
- Include specific commands, paths, and config values when relevant
- Mention potential risks or gotchas before suggesting changes
- If a relevant troubleshooting flow exists in the team's library, reference it
- Keep responses concise but thorough — prefer bullet points and code blocks
- Format code with proper markdown code blocks
"""
def _auto_title(message: str) -> str:
"""Generate a short title from the first user message."""
title = message.strip()[:100]
if len(message) > 100:
title = title.rsplit(" ", 1)[0] + "..."
return title
async def create_chat(
user_id: UUID,
account_id: UUID,
db: AsyncSession,
) -> AssistantChat:
"""Create a new empty chat."""
chat = AssistantChat(
user_id=user_id,
account_id=account_id,
messages=[],
)
db.add(chat)
await db.flush()
return chat
async def send_message(
chat_id: UUID,
user_id: UUID,
account_id: UUID,
message: str,
db: AsyncSession,
) -> tuple[str, list[dict[str, Any]], AssistantChat]:
"""Send a user message and get AI response.
Returns (ai_content, suggested_flows, chat).
"""
result = await db.execute(
select(AssistantChat).where(
AssistantChat.id == chat_id,
AssistantChat.user_id == user_id,
)
)
chat = result.scalar_one_or_none()
if not chat:
raise ValueError("Chat not found")
# Auto-title from first message
if chat.message_count == 0:
chat.title = _auto_title(message)
# RAG search
rag_results = await rag_search(
query=message,
account_id=account_id,
db=db,
limit=8,
)
# Build system prompt
system_prompt = ASSISTANT_SYSTEM_PROMPT + build_rag_context(rag_results)
# Build messages for AI
ai_messages = []
for msg in chat.messages:
if msg["role"] in ("user", "assistant"):
ai_messages.append({"role": msg["role"], "content": msg["content"]})
ai_messages.append({"role": "user", "content": message})
# Call AI
provider = get_ai_provider()
ai_content, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=ai_messages,
max_tokens=4096,
)
# Update chat
msgs = list(chat.messages)
msgs.append({"role": "user", "content": message})
msgs.append({"role": "assistant", "content": ai_content})
chat.messages = msgs
chat.message_count += 2
chat.total_input_tokens += input_tokens
chat.total_output_tokens += output_tokens
suggested_flows = extract_suggested_flows(rag_results)
return ai_content, suggested_flows, chat

View File

@@ -0,0 +1,202 @@
"""Copilot service — in-session AI assistant with RAG context.
Builds system prompts with current flow context and RAG results,
manages conversation state, and returns AI responses with flow suggestions.
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional, Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
logger = logging.getLogger(__name__)
COPILOT_SYSTEM_PROMPT = """You are a Senior Systems and Network Engineer with 15+ years of experience working in Managed Service Provider (MSP) environments. You specialize in:
- Windows Server, Active Directory, Group Policy, and Hybrid Identity (Entra ID)
- Networking (TCP/IP, DNS, DHCP, VPN, firewall troubleshooting, Cisco/Fortinet)
- Virtualization (VMware, Hyper-V) and cloud platforms (Azure, AWS, M365)
- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya)
- PowerShell scripting and automation
You are acting as an in-session copilot while the user navigates a troubleshooting or procedural flow. You can see the flow context and their current position.
When answering:
- Be direct and actionable — MSP engineers need fast, practical answers
- Include specific commands, paths, and config values when relevant
- Mention potential risks or gotchas before suggesting changes
- If a relevant troubleshooting flow exists in the team's library, reference it
- Keep responses concise but thorough — prefer bullet points and code blocks
"""
def _build_flow_context(tree: Tree, current_node_id: Optional[str]) -> str:
"""Build flow context string for the system prompt."""
parts = [
f"\n--- CURRENT FLOW CONTEXT ---",
f"Flow: {tree.name}",
f"Type: {tree.tree_type}",
]
if tree.description:
parts.append(f"Description: {tree.description}")
if current_node_id and tree.tree_structure:
node = _find_node(tree.tree_structure, current_node_id)
if node:
parts.append(f"Current node type: {node.get('type', 'unknown')}")
parts.append(f"Current node: {node.get('content', node.get('label', 'Unknown'))}")
# Add options if it's a question/decision node
children = node.get("children", [])
if children and isinstance(children, list):
option_labels = [
c.get("label", c.get("content", ""))
for c in children if isinstance(c, dict)
]
if option_labels:
parts.append(f"Available options: {', '.join(option_labels)}")
return "\n".join(parts)
def _find_node(structure: dict, node_id: str) -> Optional[dict]:
"""Recursively find a node by ID in tree structure."""
if structure.get("id") == node_id:
return structure
for child in structure.get("children", []):
if isinstance(child, dict):
found = _find_node(child, node_id)
if found:
return found
# Check steps array for procedural flows
for step in structure.get("steps", []):
if isinstance(step, dict):
found = _find_node(step, node_id)
if found:
return found
return None
async def start_conversation(
user_id: UUID,
account_id: UUID,
tree_id: UUID,
session_id: Optional[UUID],
current_node_id: Optional[str],
db: AsyncSession,
) -> tuple[CopilotConversation, str]:
"""Start a new copilot conversation.
Returns (conversation, greeting_message).
"""
# Load tree
result = await db.execute(
select(Tree).options(selectinload(Tree.tags)).where(Tree.id == tree_id)
)
tree = result.scalar_one_or_none()
if not tree:
raise ValueError(f"Tree {tree_id} not found")
conversation = CopilotConversation(
user_id=user_id,
account_id=account_id,
tree_id=tree_id,
session_id=session_id,
current_node_id=current_node_id,
messages=[],
expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
)
db.add(conversation)
await db.flush()
greeting = f"I'm your copilot for this **{tree.tree_type}** flow: **{tree.name}**. Ask me anything about the current step, alternative approaches, or related troubleshooting tips."
conversation.messages = [{"role": "assistant", "content": greeting}]
conversation.message_count = 1
return conversation, greeting
async def send_message(
conversation_id: UUID,
user_id: UUID,
message: str,
current_node_id: Optional[str],
db: AsyncSession,
) -> tuple[str, list[dict[str, Any]], CopilotConversation]:
"""Send a user message and get AI response.
Returns (ai_content, suggested_flows, conversation).
"""
result = await db.execute(
select(CopilotConversation).where(
CopilotConversation.id == conversation_id,
CopilotConversation.user_id == user_id,
)
)
conversation = result.scalar_one_or_none()
if not conversation:
raise ValueError("Conversation not found")
if conversation.expires_at < datetime.now(timezone.utc):
raise ValueError("Conversation has expired")
# Load tree for context
tree_result = await db.execute(
select(Tree).options(selectinload(Tree.tags)).where(Tree.id == conversation.tree_id)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise ValueError("Associated flow not found")
# Update current node
if current_node_id:
conversation.current_node_id = current_node_id
# RAG search
rag_results = await rag_search(
query=message,
account_id=conversation.account_id,
db=db,
limit=8,
)
# 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)
# Build messages for AI
ai_messages = []
for msg in conversation.messages:
if msg["role"] in ("user", "assistant"):
ai_messages.append({"role": msg["role"], "content": msg["content"]})
ai_messages.append({"role": "user", "content": message})
# Call AI
provider = get_ai_provider()
ai_content, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=ai_messages,
max_tokens=2048,
)
# Update conversation
msgs = list(conversation.messages)
msgs.append({"role": "user", "content": message})
msgs.append({"role": "assistant", "content": ai_content})
conversation.messages = msgs
conversation.message_count += 2
conversation.total_input_tokens += input_tokens
conversation.total_output_tokens += output_tokens
# Extract suggested flows
suggested_flows = extract_suggested_flows(rag_results, exclude_tree_id=tree.id)
return ai_content, suggested_flows, conversation

View File

@@ -0,0 +1,78 @@
"""Embedding provider abstraction for RAG.
Uses Voyage AI (voyage-3.5, 1024 dims) as the embedding provider.
Supports document and query input types for asymmetric search.
"""
import logging
from typing import Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
async def get_embedding(
text: str,
input_type: str = "document",
) -> Optional[list[float]]:
"""Get embedding vector for text using Voyage AI.
Args:
text: The text to embed.
input_type: "document" for indexing, "query" for search queries.
Returns:
List of floats (1024 dims) or None if embedding service unavailable.
"""
if not settings.VOYAGE_API_KEY:
logger.warning("VOYAGE_API_KEY not set — embedding service unavailable")
return None
try:
import voyageai
client = voyageai.AsyncClient(api_key=settings.VOYAGE_API_KEY)
result = await client.embed(
texts=[text],
model=settings.EMBEDDING_MODEL,
input_type=input_type,
)
return result.embeddings[0]
except Exception as e:
logger.error("Embedding failed: %s", e)
return None
async def get_embeddings_batch(
texts: list[str],
input_type: str = "document",
) -> Optional[list[list[float]]]:
"""Get embedding vectors for multiple texts in a single API call.
Args:
texts: List of texts to embed.
input_type: "document" for indexing, "query" for search queries.
Returns:
List of embedding vectors or None if service unavailable.
"""
if not texts:
return []
if not settings.VOYAGE_API_KEY:
logger.warning("VOYAGE_API_KEY not set — embedding service unavailable")
return None
try:
import voyageai
client = voyageai.AsyncClient(api_key=settings.VOYAGE_API_KEY)
result = await client.embed(
texts=texts,
model=settings.EMBEDDING_MODEL,
input_type=input_type,
)
return result.embeddings
except Exception as e:
logger.error("Batch embedding failed: %s", e)
return None

View File

@@ -0,0 +1,209 @@
"""RAG service — index trees and search embeddings for AI context.
Orchestrates tree chunking, embedding, and semantic search over the
team's flow library via pgvector cosine similarity.
"""
import logging
from typing import Optional, Any
from uuid import UUID
from sqlalchemy import text, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tree import Tree
from app.models.tree_embedding import TreeEmbedding
from app.services.embedding_service import get_embedding, get_embeddings_batch
from app.services.tree_chunker import chunk_tree
logger = logging.getLogger(__name__)
async def index_tree(tree_id: UUID, db: AsyncSession) -> int:
"""Chunk and embed a tree, storing results in tree_embeddings.
Deletes existing embeddings for this tree before re-indexing.
Returns the number of chunks indexed.
"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
result = await db.execute(
select(Tree)
.options(selectinload(Tree.tags))
.where(Tree.id == tree_id)
)
tree = result.scalar_one_or_none()
if not tree:
logger.warning("index_tree: tree %s not found", tree_id)
return 0
# Delete existing embeddings
await db.execute(
delete(TreeEmbedding).where(TreeEmbedding.tree_id == tree_id)
)
# Chunk the tree
tag_names = [t.name for t in tree.tags] if tree.tags else []
chunks = chunk_tree(
tree_name=tree.name,
tree_type=tree.tree_type,
description=tree.description,
tags=tag_names,
tree_structure=tree.tree_structure,
)
if not chunks:
logger.info("index_tree: no chunks for tree %s", tree_id)
return 0
# Get embeddings for all chunks in batch
texts = [c["chunk_text"] for c in chunks]
embeddings = await get_embeddings_batch(texts, input_type="document")
if embeddings is None:
logger.warning("index_tree: embedding service unavailable for tree %s", tree_id)
return 0
# Insert embeddings
for chunk, embedding in zip(chunks, embeddings):
embedding_str = "[" + ",".join(str(v) for v in embedding) + "]"
await db.execute(
text("""
INSERT INTO tree_embeddings
(tree_id, account_id, chunk_type, node_type, node_id, chunk_text, embedding_model, embedding, meta)
VALUES
(:tree_id, :account_id, :chunk_type, :node_type, :node_id, :chunk_text, :embedding_model, :embedding::vector, :meta::jsonb)
"""),
{
"tree_id": str(tree_id),
"account_id": str(tree.account_id) if tree.account_id else None,
"chunk_type": chunk["chunk_type"],
"node_type": chunk.get("node_type"),
"node_id": chunk.get("node_id"),
"chunk_text": chunk["chunk_text"],
"embedding_model": "voyage-3.5",
"embedding": embedding_str,
"meta": "{}",
},
)
logger.info("index_tree: indexed %d chunks for tree %s", len(chunks), tree_id)
return len(chunks)
async def delete_tree_embeddings(tree_id: UUID, db: AsyncSession) -> None:
"""Delete all embeddings for a tree."""
await db.execute(
delete(TreeEmbedding).where(TreeEmbedding.tree_id == tree_id)
)
async def search(
query: str,
account_id: UUID,
db: AsyncSession,
limit: int = 8,
exclude_tree_id: Optional[UUID] = None,
) -> list[dict[str, Any]]:
"""Semantic search over team's flow library.
Args:
query: Natural language search query.
account_id: Scope search to team's flows.
db: Database session.
limit: Max results to return.
exclude_tree_id: Exclude chunks from this tree (for copilot context).
Returns:
List of dicts with tree_id, tree_name, tree_type, chunk_text, chunk_type, similarity.
"""
query_embedding = await get_embedding(query, input_type="query")
if query_embedding is None:
return []
embedding_str = "[" + ",".join(str(v) for v in query_embedding) + "]"
exclude_clause = ""
params: dict[str, Any] = {
"embedding": embedding_str,
"account_id": str(account_id),
"limit": limit,
}
if exclude_tree_id:
exclude_clause = "AND te.tree_id != :exclude_tree_id"
params["exclude_tree_id"] = str(exclude_tree_id)
result = await db.execute(
text(f"""
SELECT
te.tree_id,
t.name as tree_name,
t.tree_type,
te.chunk_text,
te.chunk_type,
te.node_id,
1 - (te.embedding <=> :embedding::vector) as similarity
FROM tree_embeddings te
JOIN trees t ON t.id = te.tree_id
WHERE te.account_id = :account_id
AND t.deleted_at IS NULL
{exclude_clause}
ORDER BY te.embedding <=> :embedding::vector
LIMIT :limit
"""),
params,
)
rows = result.mappings().all()
return [
{
"tree_id": str(row["tree_id"]),
"tree_name": row["tree_name"],
"tree_type": row["tree_type"],
"chunk_text": row["chunk_text"],
"chunk_type": row["chunk_type"],
"node_id": row["node_id"],
"similarity": float(row["similarity"]),
}
for row in rows
]
def build_rag_context(rag_results: list[dict[str, Any]]) -> str:
"""Format RAG results into a system prompt section."""
if not rag_results:
return ""
parts = ["\n--- RELEVANT FLOWS FROM TEAM LIBRARY ---"]
for r in rag_results[:5]: # Cap at 5 for prompt size
parts.append(f"- [{r['tree_type']}] {r['tree_name']}: {r['chunk_text'][:200]}")
return "\n".join(parts)
def extract_suggested_flows(
rag_results: list[dict[str, Any]],
exclude_tree_id: Optional[UUID] = None,
) -> list[dict[str, Any]]:
"""Extract unique suggested flows from RAG results."""
seen_tree_ids: set[str] = set()
suggestions = []
for r in rag_results:
tid = r["tree_id"]
if exclude_tree_id and tid == str(exclude_tree_id):
continue
if tid in seen_tree_ids:
continue
if r["similarity"] < 0.3:
continue
seen_tree_ids.add(tid)
suggestions.append({
"tree_id": tid,
"tree_name": r["tree_name"],
"tree_type": r["tree_type"],
"relevance_snippet": r["chunk_text"][:150],
})
return suggestions[:3]

View File

@@ -0,0 +1,84 @@
"""Chat retention cleanup job.
Runs daily via APScheduler to enforce account-level retention settings:
- Delete non-pinned chats older than chat_retention_days
- Delete oldest non-pinned chats when count exceeds chat_retention_max_count
"""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, delete, func
from app.core.database import async_session_maker
from app.models.account import Account
from app.models.assistant_chat import AssistantChat
logger = logging.getLogger(__name__)
async def cleanup_expired_chats() -> None:
"""Enforce chat retention policies for all accounts."""
async with async_session_maker() as db:
try:
result = await db.execute(select(Account))
accounts = result.scalars().all()
total_deleted = 0
for account in accounts:
deleted = await _cleanup_account_chats(account, db)
total_deleted += deleted
await db.commit()
if total_deleted > 0:
logger.info("[retention] Cleaned up %d expired chats", total_deleted)
except Exception as e:
logger.error("[retention] Chat cleanup failed: %s", e)
await db.rollback()
async def _cleanup_account_chats(account: Account, db) -> int:
"""Enforce retention for a single account. Returns count deleted."""
deleted = 0
# Age-based retention
if account.chat_retention_days:
cutoff = datetime.now(timezone.utc) - timedelta(days=account.chat_retention_days)
result = await db.execute(
delete(AssistantChat)
.where(
AssistantChat.account_id == account.id,
AssistantChat.pinned == False, # noqa: E712
AssistantChat.updated_at < cutoff,
)
.returning(AssistantChat.id)
)
deleted += len(result.all())
# Count-based retention
if account.chat_retention_max_count:
total = await db.scalar(
select(func.count(AssistantChat.id)).where(
AssistantChat.account_id == account.id,
)
) or 0
if total > account.chat_retention_max_count:
excess = total - account.chat_retention_max_count
# Get oldest non-pinned chat IDs
oldest = await db.execute(
select(AssistantChat.id)
.where(
AssistantChat.account_id == account.id,
AssistantChat.pinned == False, # noqa: E712
)
.order_by(AssistantChat.updated_at.asc())
.limit(excess)
)
ids_to_delete = [row[0] for row in oldest.all()]
if ids_to_delete:
await db.execute(
delete(AssistantChat).where(AssistantChat.id.in_(ids_to_delete))
)
deleted += len(ids_to_delete)
return deleted

View File

@@ -0,0 +1,165 @@
"""Tree chunker — converts tree_structure JSON into embeddable text chunks.
Produces three chunk types:
- tree_summary: Name + description + tags + type overview
- node: Individual node content with breadcrumb path context
- solution: Full solution/action text with path context
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def _get_breadcrumb(node: dict, parent_path: str = "") -> str:
"""Build a breadcrumb path string for a node."""
content = node.get("content", node.get("label", ""))[:80]
if parent_path:
return f"{parent_path} > {content}"
return content
def _chunk_node(
node: dict,
tree_name: str,
tree_type: str,
tags: list[str],
parent_path: str = "",
) -> list[dict[str, Any]]:
"""Recursively chunk a node and its children."""
chunks = []
node_type = node.get("type", "unknown")
node_id = node.get("id", "")
content = node.get("content", node.get("label", ""))
breadcrumb = _get_breadcrumb(node, parent_path)
# Build chunk text based on node type
if node_type in ("question", "decision"):
options = node.get("children", [])
option_labels = [
child.get("label", child.get("content", ""))[:100]
for child in options
if isinstance(child, dict)
]
text_parts = [
f"[{node_type}] {content}",
]
if option_labels:
text_parts.append(f"Options: {', '.join(option_labels)}")
text_parts.append(f"Path: {breadcrumb}")
text_parts.append(f"Flow: {tree_name} | Type: {tree_type}")
if tags:
text_parts.append(f"Tags: {', '.join(tags)}")
chunks.append({
"chunk_type": "node",
"node_type": node_type,
"node_id": node_id,
"chunk_text": "\n".join(text_parts),
})
elif node_type in ("action", "solution", "info", "warning"):
text_parts = [
f"[{node_type}] {content}",
f"Path: {breadcrumb}",
f"Flow: {tree_name} | Type: {tree_type}",
]
if tags:
text_parts.append(f"Tags: {', '.join(tags)}")
chunk_type = "solution" if node_type == "solution" else "node"
chunks.append({
"chunk_type": chunk_type,
"node_type": node_type,
"node_id": node_id,
"chunk_text": "\n".join(text_parts),
})
elif node_type in ("step", "section_header"):
text_parts = [
f"[{node_type}] {content}",
f"Path: {breadcrumb}",
f"Flow: {tree_name} | Type: {tree_type}",
]
if node.get("description"):
text_parts.insert(1, node["description"])
if tags:
text_parts.append(f"Tags: {', '.join(tags)}")
chunks.append({
"chunk_type": "node",
"node_type": node_type,
"node_id": node_id,
"chunk_text": "\n".join(text_parts),
})
# Recurse into children
children = node.get("children", [])
if isinstance(children, list):
for child in children:
if isinstance(child, dict):
chunks.extend(
_chunk_node(child, tree_name, tree_type, tags, breadcrumb)
)
# Follow next_node_id linked nodes (action nodes)
# These are handled at the tree level, not recursively
return chunks
def chunk_tree(
tree_name: str,
tree_type: str,
description: str | None,
tags: list[str],
tree_structure: dict[str, Any],
) -> list[dict[str, Any]]:
"""Convert a tree into embeddable text chunks.
Args:
tree_name: Name of the flow.
tree_type: troubleshooting | procedural | maintenance.
description: Flow description.
tags: List of tag names.
tree_structure: The tree_structure JSONB content.
Returns:
List of chunk dicts with keys: chunk_type, node_type, node_id, chunk_text.
"""
chunks = []
# Tree summary chunk
summary_parts = [
f"Flow: {tree_name}",
f"Type: {tree_type}",
]
if description:
summary_parts.append(f"Description: {description}")
if tags:
summary_parts.append(f"Tags: {', '.join(tags)}")
chunks.append({
"chunk_type": "tree_summary",
"node_type": None,
"node_id": None,
"chunk_text": "\n".join(summary_parts),
})
# Chunk the tree structure nodes
root = tree_structure
if isinstance(root, dict):
# Handle both flat structure and nested
if "children" in root or "type" in root:
chunks.extend(
_chunk_node(root, tree_name, tree_type, tags)
)
# Handle steps array (procedural flows)
if "steps" in root and isinstance(root["steps"], list):
for step in root["steps"]:
if isinstance(step, dict):
chunks.extend(
_chunk_node(step, tree_name, tree_type, tags)
)
return chunks

View File

@@ -35,6 +35,10 @@ httpx>=0.27.0
anthropic>=0.40.0
google-genai>=1.0.0
# RAG / Embeddings
pgvector>=0.3.6
voyageai>=0.3.0
# Utilities
python-dotenv==1.0.1
croniter>=2.0.0

View File

@@ -0,0 +1,109 @@
"""Tests for leave account and delete account endpoints."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestLeaveAccount:
"""Test POST /accounts/me/leave."""
async def test_leave_as_non_owner(self, client: AsyncClient, test_db):
"""Non-owner can leave and gets a personal account."""
from sqlalchemy import select
from app.models.user import User
# Register owner
owner = await client.post("/api/v1/auth/register", json={
"email": "owner@example.com", "password": "TestPassword123!", "name": "Owner",
})
assert owner.status_code == 201
owner_data = owner.json()
# Login as owner
login = await client.post("/api/v1/auth/login/json", json={
"email": "owner@example.com", "password": "TestPassword123!",
})
owner_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
# Register member
member = await client.post("/api/v1/auth/register", json={
"email": "member@example.com", "password": "TestPassword123!", "name": "Member",
})
member_id = member.json()["id"]
# Move member to owner's account
result = await test_db.execute(select(User).where(User.id == member_id))
member_user = result.scalar_one()
member_user.account_id = owner_data["account_id"]
member_user.account_role = "engineer"
await test_db.commit()
# Login as member
login = await client.post("/api/v1/auth/login/json", json={
"email": "member@example.com", "password": "TestPassword123!",
})
member_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
# Leave
response = await client.post("/api/v1/accounts/me/leave", headers=member_headers)
assert response.status_code == 200
async def test_leave_as_owner_fails(self, client: AsyncClient, auth_headers: dict):
"""Owner cannot leave their own account."""
response = await client.post("/api/v1/accounts/me/leave", headers=auth_headers)
assert response.status_code == 400
@pytest.mark.asyncio
class TestDeleteAccount:
"""Test DELETE /accounts/me."""
async def test_delete_success(self, client: AsyncClient, auth_headers: dict):
"""Owner with no other members can delete account."""
response = await client.request(
"DELETE",
"/api/v1/accounts/me",
json={"current_password": "TestPassword123!"},
headers=auth_headers,
)
assert response.status_code == 200
async def test_delete_wrong_password(self, client: AsyncClient, auth_headers: dict):
"""Wrong password returns 401."""
response = await client.request(
"DELETE",
"/api/v1/accounts/me",
json={"current_password": "WrongPassword123!"},
headers=auth_headers,
)
assert response.status_code == 401
async def test_delete_with_members_fails(self, client: AsyncClient, auth_headers: dict, test_db):
"""Cannot delete account that has other members."""
from sqlalchemy import select
from app.models.user import User
# Get owner's account_id
me = await client.get("/api/v1/auth/me", headers=auth_headers)
account_id = me.json()["account_id"]
# Register and add member
member = await client.post("/api/v1/auth/register", json={
"email": "member2@example.com", "password": "TestPassword123!", "name": "Member",
})
member_id = member.json()["id"]
result = await test_db.execute(select(User).where(User.id == member_id))
member_user = result.scalar_one()
member_user.account_id = account_id
member_user.account_role = "engineer"
await test_db.commit()
response = await client.request(
"DELETE",
"/api/v1/accounts/me",
json={"current_password": "TestPassword123!"},
headers=auth_headers,
)
assert response.status_code == 400

View File

@@ -0,0 +1,63 @@
"""Tests for account ownership transfer."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestOwnershipTransfer:
"""Test POST /accounts/me/transfer-ownership."""
async def _create_member(self, client: AsyncClient, owner_headers: dict, test_db):
"""Register a second user and add them to the owner's account."""
from sqlalchemy import select
from app.models.user import User
# Register second user (gets own account)
resp = await client.post("/api/v1/auth/register", json={
"email": "member@example.com",
"password": "TestPassword123!",
"name": "Member User",
})
assert resp.status_code == 201
member_id = resp.json()["id"]
# Get owner's account_id
me = await client.get("/api/v1/auth/me", headers=owner_headers)
owner_account_id = me.json()["account_id"]
# Move member to owner's account
result = await test_db.execute(select(User).where(User.id == member_id))
member = result.scalar_one()
member.account_id = owner_account_id
member.account_role = "engineer"
await test_db.commit()
return member_id
async def test_transfer_success(self, client: AsyncClient, auth_headers: dict, test_db):
member_id = await self._create_member(client, auth_headers, test_db)
response = await client.post(
"/api/v1/accounts/me/transfer-ownership",
json={"current_password": "TestPassword123!", "target_user_id": member_id},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["owner_id"] == member_id
async def test_transfer_self(self, client: AsyncClient, auth_headers: dict, test_user):
response = await client.post(
"/api/v1/accounts/me/transfer-ownership",
json={"current_password": "TestPassword123!", "target_user_id": test_user["user_data"]["id"]},
headers=auth_headers,
)
assert response.status_code == 400
async def test_transfer_wrong_password(self, client: AsyncClient, auth_headers: dict, test_db):
member_id = await self._create_member(client, auth_headers, test_db)
response = await client.post(
"/api/v1/accounts/me/transfer-ownership",
json={"current_password": "WrongPassword123!", "target_user_id": member_id},
headers=auth_headers,
)
assert response.status_code == 401

View File

@@ -0,0 +1,90 @@
"""Tests for PATCH /auth/me profile update endpoint."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestProfileUpdate:
"""Test profile update via PATCH /auth/me."""
async def test_update_name(self, client: AsyncClient, auth_headers: dict):
"""Name update works without password."""
response = await client.patch(
"/api/v1/auth/me",
json={"name": "New Name"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["name"] == "New Name"
async def test_update_email_with_password(self, client: AsyncClient, auth_headers: dict):
"""Email change with correct password succeeds."""
response = await client.patch(
"/api/v1/auth/me",
json={"email": "newemail@example.com", "current_password": "TestPassword123!"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["email"] == "newemail@example.com"
async def test_update_email_without_password(self, client: AsyncClient, auth_headers: dict):
"""Email change without password returns 400."""
response = await client.patch(
"/api/v1/auth/me",
json={"email": "newemail@example.com"},
headers=auth_headers,
)
assert response.status_code == 400
assert "password" in response.json()["detail"].lower()
async def test_update_email_wrong_password(self, client: AsyncClient, auth_headers: dict):
"""Email change with wrong password returns 401."""
response = await client.patch(
"/api/v1/auth/me",
json={"email": "newemail@example.com", "current_password": "WrongPassword123!"},
headers=auth_headers,
)
assert response.status_code == 401
async def test_update_email_duplicate(self, client: AsyncClient, auth_headers: dict):
"""Email change to existing email returns 400."""
# Register second user
await client.post("/api/v1/auth/register", json={
"email": "other@example.com",
"password": "TestPassword123!",
"name": "Other User",
})
response = await client.patch(
"/api/v1/auth/me",
json={"email": "other@example.com", "current_password": "TestPassword123!"},
headers=auth_headers,
)
assert response.status_code == 400
assert "already registered" in response.json()["detail"].lower()
async def test_get_me_returns_updated_name(self, client: AsyncClient, auth_headers: dict):
"""GET /me reflects the updated profile."""
await client.patch(
"/api/v1/auth/me",
json={"name": "Updated User"},
headers=auth_headers,
)
response = await client.get("/api/v1/auth/me", headers=auth_headers)
assert response.status_code == 200
assert response.json()["name"] == "Updated User"
async def test_no_changes_returns_current_user(self, client: AsyncClient, auth_headers: dict):
"""Empty update returns current user without error."""
response = await client.patch(
"/api/v1/auth/me",
json={},
headers=auth_headers,
)
assert response.status_code == 200
async def test_unauthenticated(self, client: AsyncClient):
"""Unauthenticated request returns 401."""
response = await client.patch("/api/v1/auth/me", json={"name": "X"})
assert response.status_code == 401

View File

@@ -0,0 +1,57 @@
"""Tests for email verification endpoints."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
class TestEmailVerification:
"""Test email verification send + verify flow."""
async def test_send_verification(self, client: AsyncClient, auth_headers: dict):
"""Send verification email returns 200."""
response = await client.post(
"/api/v1/auth/email/send-verification",
headers=auth_headers,
)
assert response.status_code == 200
assert "sent" in response.json()["message"].lower()
async def test_send_verification_already_verified(
self, client: AsyncClient, auth_headers: dict, test_db
):
"""Returns 400 if email is already verified."""
from sqlalchemy import select, update
from datetime import datetime, timezone
from app.models.user import User
# Manually mark email as verified
await test_db.execute(
update(User).where(User.email == "test@example.com").values(
email_verified_at=datetime.now(timezone.utc)
)
)
await test_db.commit()
response = await client.post(
"/api/v1/auth/email/send-verification",
headers=auth_headers,
)
assert response.status_code == 400
assert "already verified" in response.json()["detail"].lower()
async def test_verify_invalid_token(self, client: AsyncClient):
"""Invalid token returns 400."""
response = await client.post(
"/api/v1/auth/email/verify",
json={"token": "invalid-token"},
)
assert response.status_code == 400
async def test_verify_missing_token(self, client: AsyncClient):
"""Missing token returns 400."""
response = await client.post(
"/api/v1/auth/email/verify",
json={},
)
assert response.status_code == 400

View File

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

View File

@@ -0,0 +1,263 @@
# Frontend Standardization: Full Audit & Fix
## Objective
Perform a comprehensive audit of the entire ResolutionFlow frontend and standardize four UI patterns that are currently inconsistent across the application. Create reusable components where they don't exist, then retrofit every page and component to use them. When done, the entire app should feel like one developer built every page.
Use every tool, agent, and skill at your disposal. Search every file. Don't ask — fix.
---
## Pattern 1: Loading States → Replace All Spinners with Skeleton Components
### The Problem
Loading states are implemented differently across the app. Some pages use a centered spinning circle, others use pulse-animated rectangles, and some use nothing. There is no shared reusable skeleton component.
### Current Inconsistencies to Find and Fix
**Spinner pattern (replace everywhere you find it):**
```tsx
// THIS PATTERN — find every instance and replace with skeletons
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
```
**Known locations (search for more):**
- `frontend/src/pages/SessionHistoryPage.tsx` — uses spinner
- `frontend/src/pages/TreeLibraryPage.tsx` — uses spinner
- `frontend/src/components/step-library/StepLibraryBrowser.tsx` — uses spinner or loading boolean
- `frontend/src/pages/QuickStartPage.tsx` — uses spinner or loading state
- `frontend/src/pages/TreeEditorPage.tsx` — check for loading state
- `frontend/src/pages/SessionDetailPage.tsx` — check for loading state
- `frontend/src/pages/TreeNavigationPage.tsx` — check for loading state
- `frontend/src/pages/MyTreesPage.tsx` — check for loading state
- Any other page or component with `isLoading` state
### What to Build
**Create `frontend/src/components/common/Skeleton.tsx`:**
A set of composable skeleton primitives:
```tsx
// Base skeleton block with pulse animation
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse rounded bg-muted", className)} />
}
// Pre-built skeleton layouts for common patterns:
export function SkeletonCard() { /* Card-shaped skeleton matching TreeGridView card dimensions */ }
export function SkeletonRow() { /* Row-shaped skeleton matching TreeListView row dimensions */ }
export function SkeletonTableRow() { /* Table row skeleton matching TreeTableView row */ }
export function SkeletonText({ lines = 3 }: { lines?: number }) { /* Text block skeleton */ }
```
### The Standard (enforce everywhere)
- **Page-level data loading:** Show skeleton placeholders that match the shape of the content that will appear. Grid pages get skeleton cards. List pages get skeleton rows. Detail pages get skeleton text blocks.
- **Component-level loading:** Small inline skeletons (e.g., a single line for a name loading).
- **Never show a centered spinner.** The only acceptable spinner is on a button that is performing an action (e.g., "Saving..." with a small inline spinner). Page/section loading always uses skeletons.
- **Skeleton count should match expected content.** If a page typically shows 6 cards, show 6 skeleton cards. If a list shows 10 rows, show 10 skeleton rows.
### Audit Instructions
1. Search the entire `frontend/src/` directory for: `animate-spin`, `border-t-transparent`, `Loader2` (Lucide spinner icon), and any `isLoading` state variable.
2. For every match, determine if it's a page/section loading state or a button action state.
3. Replace all page/section loading states with appropriate skeleton components.
4. Leave button-level spinners alone (e.g., "Saving..." on submit buttons is fine).
---
## Pattern 2: Empty States → Add Meaningful Messages + CTAs Everywhere
### The Problem
Empty states across the app are bare text with no guidance and no call to action. A new user sees "No sessions found." or "No trees found." and has no idea what to do next.
### Current Inconsistencies to Find and Fix
**Known bare empty states:**
- `SessionHistoryPage.tsx`: `"No sessions found."` — no CTA
- `TreeLibraryPage.tsx`: `"No trees found. Try adjusting your filters."` — has filter hint but no create CTA
- `StepLibraryBrowser.tsx`: check for empty state pattern
- `QuickStartPage.tsx`: check for empty state pattern
- `MyTreesPage.tsx`: check for empty state pattern
- Any page that renders a list and has a `length === 0` check
### What to Build
**Create `frontend/src/components/common/EmptyState.tsx`:**
```tsx
interface EmptyStateProps {
icon?: React.ReactNode // Optional icon above the message
title: string // e.g., "No sessions yet"
description?: string // e.g., "Start a troubleshooting session to see it here"
action?: {
label: string // e.g., "Start a Session"
onClick: () => void
}
secondaryAction?: {
label: string // e.g., "Clear filters"
onClick: () => void
}
}
```
### The Standard (enforce everywhere)
Every empty state must have:
1. **A clear title** that says what's empty (not just "No results")
2. **A description** that tells the user why it's empty or what to do
3. **A primary CTA** (when applicable) that lets them take the obvious next action
4. **A secondary action** (when applicable) for filter/search scenarios: "Clear filters" or "Try a different search"
**Context-specific empty states:**
| Page / Section | Title | Description | CTA |
|---|---|---|---|
| Session History (no sessions at all) | "No sessions yet" | "Start a troubleshooting session to see your history here" | "Browse Flows" → navigate to /trees |
| Session History (filter returns empty) | "No matching sessions" | "Try adjusting your filters" | "Clear filters" → reset filter |
| Tree Library (no trees at all) | "No flows available" | "Create your first troubleshooting flow to get started" | "Create Flow" → open create dropdown/navigate |
| Tree Library (search returns empty) | "No flows match your search" | "Try different keywords or clear your filters" | "Clear search" → reset search |
| My Trees (no authored trees) | "You haven't created any flows yet" | "Build a troubleshooting flow to guide your team" | "Create Flow" → open create dropdown |
| Step Library (no steps) | "No steps found" | "Create your first reusable step" | "Create Step" → open create form |
| Dashboard Favorites (no pins) | "No favorites yet" | "Star a flow to pin it here for quick access" | No button (action is contextual) |
| Dashboard My Flows (no authored flows) | "You haven't created any flows yet" | "Build your first troubleshooting flow" | "Create your first flow" → open create dropdown |
### Audit Instructions
1. Search `frontend/src/` for: `length === 0`, `.length === 0`, `sessions.length`, `trees.length`, and any conditional rendering that shows text when a list is empty.
2. For every match, check if the empty state has a title, description, and CTA.
3. Replace bare text empty states with the `EmptyState` component using the appropriate context from the table above.
4. If you find an empty state not listed in the table, follow the same pattern: clear title, helpful description, obvious next action.
---
## Pattern 3: Accessibility — Aria Labels on All Icon-Only Buttons
### The Problem
Some icon-only buttons have proper `aria-label` attributes (e.g., `ThemeToggle` uses `aria-label={`Switch to ${label} theme`}`), but most don't. Icon buttons without aria-labels are invisible to screen readers.
### The Gold Standard (already in the codebase)
From `ThemeToggle.tsx`:
```tsx
<button
onClick={() => setTheme(value)}
className={cn('rounded p-1.5 transition-colors', ...)}
aria-label={`Switch to ${label} theme`}
aria-pressed={theme === value}
>
<Icon className="h-4 w-4" />
</button>
```
From `StepDetailModal.tsx`:
```tsx
<button
onClick={onClose}
className="rounded-md p-1 hover:bg-accent"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
```
### The Standard (enforce everywhere)
Every `<button>` that contains only an icon (no visible text) MUST have:
1. `aria-label="Descriptive action"` — describes what the button does, not what the icon looks like
2. For toggle buttons: `aria-pressed={isActive}` (like the ThemeToggle pattern)
3. For buttons inside clickable parent containers (cards, rows): `e.stopPropagation()` and `e.preventDefault()` in the onClick handler
**Common icon buttons to look for:**
- Close buttons (X icon) → `aria-label="Close"`
- Delete buttons (Trash icon) → `aria-label="Delete [thing]"`
- Edit buttons (Pencil icon) → `aria-label="Edit [thing]"`
- Filter clear buttons (X in a chip) → `aria-label="Remove [filter name] filter"`
- Expand/collapse buttons (ChevronDown/Up) → `aria-label="Expand"` / `aria-label="Collapse"`
- Copy buttons → `aria-label="Copy to clipboard"`
- Pin/favorite buttons → `aria-label="Add to favorites"` / `aria-label="Remove from favorites"`
- Sort buttons → `aria-label="Sort by [field]"`
- Navigation arrows → `aria-label="Previous page"` / `aria-label="Next page"`
- Menu/hamburger buttons → `aria-label="Open menu"`
- Any icon-only button with `<SomeLucideIcon />` and no text sibling
### Audit Instructions
1. Search `frontend/src/` for all `<button` elements.
2. For each button, check if it contains visible text content (a text node or a `<span>` with text).
3. If the button contains ONLY an icon (Lucide component, SVG, or image) with no visible text, verify it has an `aria-label`.
4. If `aria-label` is missing, add an appropriate one based on the button's purpose (check the onClick handler and surrounding context to determine what the button does).
5. For toggle buttons (pin/unpin, expand/collapse, theme), add `aria-pressed` where appropriate.
6. Also check for `<a>` tags that contain only icons — these need `aria-label` too.
---
## Pattern 4: Error Banners (Verification Only)
### The Current Pattern (already consistent — verify it stays that way)
```tsx
{error && (
<div className="mb-6 rounded-md bg-destructive/10 p-4 text-destructive">
{error}
</div>
)}
```
### Audit Instructions
1. Search for all error display patterns in `frontend/src/`.
2. Verify they all use the same `bg-destructive/10 p-4 text-destructive` pattern.
3. If any page uses a different error display style, update it to match.
4. Do NOT change this pattern — just verify consistency.
---
## Execution Order
1. **Create the reusable components first:**
- `frontend/src/components/common/Skeleton.tsx`
- `frontend/src/components/common/EmptyState.tsx`
2. **Audit and fix Pattern 1 (Loading Skeletons):**
- Search for every spinner and `isLoading` pattern
- Replace with appropriate skeleton components
- Verify each page renders skeletons that match the shape of expected content
3. **Audit and fix Pattern 2 (Empty States):**
- Search for every empty list/empty state pattern
- Replace with `EmptyState` component using context-appropriate messaging
- Ensure every empty state has at minimum a title and description
4. **Audit and fix Pattern 3 (Aria Labels):**
- Search for every icon-only button
- Add `aria-label` to every one that's missing it
- Add `aria-pressed` to toggle buttons
5. **Verify Pattern 4 (Error Banners):**
- Quick scan to confirm consistency
- Fix any outliers
6. **Final verification:**
- `cd frontend && npm run build` — must pass with zero errors
- `cd frontend && npm run test` — must pass
- Visually spot-check: no page should show a centered spinner anymore
---
## Rules
- Do NOT change any business logic. Only change presentation/UI patterns.
- Do NOT change any API calls or data fetching logic.
- Do NOT add new dependencies. Use only Tailwind CSS utilities and existing project patterns.
- Do NOT change the error banner pattern — it's already correct.
- DO use `cn()` from `@/lib/utils` for conditional class merging (existing project standard).
- DO use Lucide icons (existing project standard). No `title` prop on Lucide — wrap in `<span title="...">` if tooltip is needed.
- DO support dark mode in all new components (use Tailwind's `text-foreground`, `bg-muted`, `text-muted-foreground` etc., not hardcoded colors).
- DO keep the new components simple. No over-engineering. The `Skeleton` and `EmptyState` components should be under 100 lines each.

View File

@@ -0,0 +1,368 @@
# ResolutionFlow Aesthetic Redesign — Design Document
> **Date:** March 3, 2026
> **Status:** Approved
> **Reference Mockup:** `/tmp/mockup-j-slate-ice-modern.html`
---
## Problem Statement
The current purple gradient theme (`#818cf8``#a78bfa`) feels generic and AI-generated. It doesn't convey the professional credibility MSP engineers expect from their daily tooling. The redesign aims for a **sharp, modern** aesthetic that stands out while remaining easy on the eyes during long troubleshooting sessions.
## Design Direction: Slate & Ice Modern
Dark glassmorphism with an ice-cyan accent. Cool charcoal backgrounds, frosted-glass cards with backdrop blur, orchestrated page-load animations, and bold display typography.
---
## Color Palette
### Core Colors
| Token | Hex | Usage |
|-------|-----|-------|
| `--background` | `#101114` | Page background, body |
| `--surface` | `#14161a` | Sidebar/topbar base behind blur |
| `--card` / glass-bg | `rgba(24, 26, 31, 0.55)` | Card backgrounds (semi-transparent) |
| `--card-hover` | `rgba(24, 26, 31, 0.7)` | Card hover state |
| `--foreground` | `#f8fafc` | Primary text |
| `--muted-foreground` | `#8891a0` | Secondary text, nav labels |
| `--muted-dim` | `#5a6170` | Section labels, timestamps |
| `--border` | `rgba(255, 255, 255, 0.06)` | Default borders |
| `--border-hover` | `rgba(255, 255, 255, 0.12)` | Hover/active borders |
### Accent Colors
| Token | Hex | Usage |
|-------|-----|-------|
| `--primary` | `#06b6d4` | Accent gradient start, active indicators |
| `--primary-light` | `#22d3ee` | Accent gradient end, highlights |
| `--gradient-brand` | `linear-gradient(135deg, #06b6d4, #22d3ee)` | Primary buttons, avatar, active accent bar, logo "Flow" text |
### Functional Colors (unchanged semantics)
| Token | Hex | Usage |
|-------|-----|-------|
| `--success` | `#34d399` / emerald-400 | Completed, positive |
| `--warning` | `#fbbf24` / amber-400 | In-progress, caution |
| `--error` | `#f43f5e` / rose-500 | Error, critical, notification dots |
| `--info` | `#60a5fa` / blue-400 | Informational |
### Accessibility Notes
- Cyan accent is safe for deuteranopia, protanopia, and tritanopia
- Always pair status colors with icons (not color alone)
- Use shape differentiation (filled vs outline icons) alongside color for colorblind users
- `#f8fafc` on `#101114` background exceeds WCAG AAA contrast ratio
---
## Typography
### Font Stack
| Role | Font | Weights | Google Fonts |
|------|------|---------|-------------|
| `font-heading` | **Bricolage Grotesque** | 400, 600, 700, 800 | Yes |
| `font-body` (default) | **IBM Plex Sans** | 400, 500, 600 | Yes |
| `font-label` | **JetBrains Mono** | 400, 500 | Yes |
### Hierarchy
| Element | Font | Size | Weight | Color |
|---------|------|------|--------|-------|
| Page greeting / hero | Bricolage Grotesque | 36px | 800 | `--foreground` |
| Stat values | Bricolage Grotesque | 30px | 800 | cyan gradient text |
| Card titles | Bricolage Grotesque | 16px | 700 | `--foreground` |
| Body text | IBM Plex Sans | 14px | 400-500 | `--foreground` |
| Nav items | IBM Plex Sans | 14px | 500 | `--muted-foreground``--foreground` on hover/active |
| Section labels | JetBrains Mono | 10px | 500 | `--muted-dim`, uppercase, `letter-spacing: 0.1em` |
| Timestamps / metadata | JetBrains Mono | 11-12px | 400 | `--muted-foreground` |
| Stat labels | IBM Plex Sans | 13px | 500 | `--muted-foreground` |
---
## Glassmorphism System
### Card Variants
**Interactive glass card** (`.glass-card`):
```css
background: rgba(24, 26, 31, 0.55);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
/* Hover */
transform: scale(1.02);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
```
**Static glass card** (`.glass-card-static`): Same as above without hover transform.
### Shell Blur Levels
| Element | Blur | Background |
|---------|------|-----------|
| Sidebar | `blur(12px)` | `rgba(16, 17, 20, 0.5)` |
| Topbar | `blur(20px)` | `rgba(16, 17, 20, 0.6)` |
| Cards | `blur(16px)` | `rgba(24, 26, 31, 0.55)` |
### Ambient Atmosphere
Two fixed `pointer-events: none` gradient orbs behind the app shell:
- **Cyan orb**: top-right, 600x600px, `rgba(6, 182, 212, 0.15)`, blur(60px)
- **Purple orb**: bottom-left, 500x500px, `rgba(99, 102, 241, 0.08)`, blur(50px)
---
## Component Specifications
### Primary Button
```css
background: linear-gradient(135deg, #06b6d4, #22d3ee);
color: #101114;
font-weight: 600;
border-radius: 10px;
padding: 10px 20px;
/* Hover: opacity 0.9; Active: scale(0.97) */
```
### Secondary Button
```css
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: #f8fafc;
border-radius: 10px;
/* Hover: border brightens to rgba(255, 255, 255, 0.12) */
```
### Search Bar
- `width: 320px`, expands to `400px` on focus
- Background: `rgba(255, 255, 255, 0.04)`, focus: `rgba(255, 255, 255, 0.06)`
- Focus border: `rgba(6, 182, 212, 0.3)` — cyan tint
- Rounded: `border-radius: 12px`
### Active Nav Item
- Background: `rgba(6, 182, 212, 0.1)` with scaleX reveal animation
- Left accent bar: 3px wide, cyan gradient, `border-radius: 0 3px 3px 0`
- Text: `--foreground` (white)
### Avatar
- 34x34px, `border-radius: 10px` (rounded square)
- Cyan gradient background, dark text
- Hover: `scale(1.08)`
### Notification Dot
- 8px circle, `#f43f5e` (rose), 2px solid `#101114` border
### Scrollbar
- 6px wide, transparent track
- Thumb: `rgba(255,255,255,0.08)`, hover: `rgba(255,255,255,0.12)`
---
## Animations
### Page Load Sequence (orchestrated)
| Element | Animation | Delay | Duration |
|---------|-----------|-------|----------|
| Topbar | slideDown (Y: -100% → 0) | 200ms | 400ms |
| Sidebar | slideInLeft (X: -100% → 0) | 250ms | 400ms |
| Greeting | fadeInUp (Y: 20px → 0) | 400ms | 400ms |
| Stat cards | fadeInUp cascade | 500ms, 570ms, 640ms, 710ms | 350ms each |
| Activity items | fadeInUp stagger | 750ms + 40ms each | 300ms each |
| Quick actions | fadeInRight (X: 30px → 0) | 800ms | 400ms |
### Micro-interactions
| Element | Effect |
|---------|--------|
| Glass cards | `scale(1.02)` + border/shadow upgrade on hover |
| Buttons | `scale(0.97)` on `:active` |
| Notification bell | wobble keyframe (rotate ±8° → 0) on hover |
| First stat card | `breatheGlow` — pulsing cyan shadow, 3s infinite |
| Nav items | Background scaleX reveal from left on hover |
| Search bar | Width expansion 320→400px on focus |
| Avatar | `scale(1.08)` on hover |
### Easing
- Primary: `cubic-bezier(0.4, 0, 0.2, 1)` — smooth deceleration
- Bounce (optional): `cubic-bezier(0.34, 1.56, 0.64, 1)` — slight overshoot
---
## Dashboard Layout
### Grid Structure
```
Row 1: Greeting + date (full width)
Row 2: Weekly Calendar (flex-grow) + Quick Actions (fixed width) — equal height
Row 3: My Open Sessions (flex-grow) + Stats 2x2 grid (fixed width) — equal height
Row 4: Recent Activity (full width)
```
### Weekly Calendar Panel
- 5 tall day columns (MonFri), equal width
- Today column: highlighted with cyan gradient top bar
- Events appear inline within day columns with colored left border (4px)
- Cyan left-border: default events
- Amber left-border: maintenance events
- Empty days show "No events" in muted text
- Calendar and quick actions stretch to match height (`align-items: stretch`)
- Future: Outlook/Gmail/PSA calendar sync integration
### Quick Actions Panel
4 glass cards in a vertical stack:
1. New Flow (+ icon, cyan accent)
2. Resume Session (play icon, emerald accent)
3. Browse Library (book icon, amber accent)
4. Invite Team (user-plus icon, purple accent)
### My Open Sessions Panel
- Shows 3 oldest open sessions
- Each row: colored dot + flow name + "Step X of Y" + time ago + Resume button
- Resume button: small cyan gradient pill
### Stats Panel (2x2 Grid)
4 stat cards:
1. Active Flows — with `breatheGlow` animation
2. This Week (sessions)
3. Avg Resolution (time)
4. Team Members
Each: stat value (30px Bricolage Grotesque, cyan gradient text) + label + trend indicator
### Recent Activity Panel
- Full width, 5 activity items
- Each: icon (colored background circle) + description + JetBrains Mono timestamp
- Staggered fadeInUp animation on page load
---
## Sidebar Structure
1. **Logo bar** (56px height, matches topbar): Decision-tree icon SVG + "Resolution" white + "Flow" cyan gradient
2. **Pinned Flows**: 3 pinned items with cyan pin icons
3. **Divider**
4. **Navigation**:
- Dashboard (active)
- All Flows → Troubleshooting / Projects / Maintenance (sub-items)
- Step Library
- Sessions
- Exports
5. **Divider**
6. **Footer** (pushed to bottom): User avatar + name + role badge
No categories section. No workspace switcher.
---
## Topbar Structure
- Left: Search bar with search icon + "Search flows..." placeholder + keyboard shortcut badge
- Right: Help icon + Notification bell (with dot) + User avatar (rounded square, cyan gradient)
- Subtle cyan gradient underline glow at center-bottom
---
## Logo
The existing decision-tree SVG icon is retained but recolored with the cyan gradient (`#06b6d4``#22d3ee`). Nodes have decreasing opacity down the tree (0.9 → 0.7 → 0.5). Connector lines use the gradient stroke at 0.40.5 opacity.
Wordmark: "Resolution" in `#f8fafc` + "Flow" with `background: linear-gradient(135deg, #06b6d4, #22d3ee)` + `-webkit-background-clip: text`.
---
## Shadow System
| Token | Value | Usage |
|-------|-------|-------|
| `--shadow-float` | `0 8px 32px rgba(0,0,0,0.3)` | Default card shadow |
| `--shadow-float-hover` | `0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.08)` | Hovered card |
| `--shadow-cyan-glow` | `0 8px 32px rgba(6,182,212,0.08)` | Cyan-tinted glow |
---
## Migration Notes
### What Changes
| Current | New |
|---------|-----|
| Purple gradient (`#818cf8``#a78bfa`) | Ice cyan gradient (`#06b6d4``#22d3ee`) |
| Plus Jakarta Sans (headings) | Bricolage Grotesque (headings) |
| Inter (body) | IBM Plex Sans (body) |
| Outfit (labels) | JetBrains Mono (labels) |
| Flat `bg-card` cards | Glassmorphism with `backdrop-filter: blur()` |
| No page-load animations | Orchestrated entrance sequence |
| No hover scaling on cards | `scale(1.02)` hover lift |
| `bg-gradient-brand` = purple | `bg-gradient-brand` = cyan |
| `text-gradient-brand` = purple | `text-gradient-brand` = cyan |
### What Stays the Same
- CSS Grid app shell layout (sidebar + topbar + main)
- Dark-first theme (dark only, no light mode)
- Lucide React icons
- Zustand state management
- Component architecture and routing
- Functional color semantics (green=success, amber=warning, red=error)
- "Flows" terminology, "ResolutionFlow" branding
- BrandLogo.tsx component structure (just recolor the SVG + gradient)
### New Dashboard Panels (Feature Work)
- **Weekly Calendar**: New component, requires date logic, event display, future calendar sync API
- **My Open Sessions**: Queries 3 oldest open sessions (existing API with sort + limit)
- Stat cards and Recent Activity already exist — layout rearrangement only
---
## Implementation Scope
### Phase 1: Design System Foundation
- Update CSS variables in `index.css`
- Update `tailwind.config.js` (colors, fonts, gradients)
- Add Google Fonts imports (Bricolage Grotesque, IBM Plex Sans, JetBrains Mono)
- Create glassmorphism utility classes
- Create animation keyframes and stagger classes
- Update `BrandLogo.tsx` SVG colors
### Phase 2: Shell & Navigation
- Update sidebar glassmorphism + nav item styles
- Update topbar glassmorphism + search bar
- Update active nav indicator (purple → cyan accent bar)
### Phase 3: Component Updates
- Update button variants (primary gradient, secondary)
- Update card components to glass-card pattern
- Update stat cards, activity items, badges
- Update form inputs (focus states)
### Phase 4: Dashboard Redesign
- Rearrange dashboard layout (greeting → calendar+actions → sessions+stats → activity)
- Build Weekly Calendar component
- Build My Open Sessions panel
- Add orchestrated page-load animations
### Phase 5: Page-by-Page Sweep
- Update all remaining pages to use new design tokens
- Ensure consistency across tree editor, session pages, admin pages, etc.

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,11 @@
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;700;800&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- PWA Icons -->
<link rel="apple-touch-icon" href="/icons/app-icon-gradient.svg" />
<meta name="theme-color" content="#09090b" />
<meta name="theme-color" content="#101114" />
<script>
// Prevent flash of wrong theme on initial load

View File

@@ -8,12 +8,19 @@ server {
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# index.html — never cache (so deploys serve new chunk references)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Handle SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
# Cache hashed static assets (immutable — filenames change on rebuild)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";

View File

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

View File

@@ -0,0 +1,59 @@
import apiClient from './client'
import type {
AssistantChat,
ChatListItem,
ChatMessageResponse,
RetentionSettings,
} from '@/types/assistant-chat'
export const assistantChatApi = {
async createChat(): Promise<AssistantChat> {
const response = await apiClient.post<AssistantChat>('/assistant/chats', {})
return response.data
},
async listChats(page = 1, size = 20): Promise<ChatListItem[]> {
const response = await apiClient.get<ChatListItem[]>('/assistant/chats', {
params: { page, size },
})
return response.data
},
async getChat(chatId: string): Promise<AssistantChat> {
const response = await apiClient.get<AssistantChat>(`/assistant/chats/${chatId}`)
return response.data
},
async sendMessage(chatId: string, message: string): Promise<ChatMessageResponse> {
const response = await apiClient.post<ChatMessageResponse>(
`/assistant/chats/${chatId}/messages`,
{ message }
)
return response.data
},
async updateChat(chatId: string, data: { title?: string; pinned?: boolean }): Promise<AssistantChat> {
const response = await apiClient.patch<AssistantChat>(`/assistant/chats/${chatId}`, data)
return response.data
},
async deleteChat(chatId: string): Promise<void> {
await apiClient.delete(`/assistant/chats/${chatId}`)
},
async bulkDeleteChats(olderThanDays: number): Promise<void> {
await apiClient.delete('/assistant/chats', { params: { older_than_days: olderThanDays } })
},
async getRetentionSettings(): Promise<RetentionSettings> {
const response = await apiClient.get<RetentionSettings>('/assistant/retention')
return response.data
},
async updateRetentionSettings(data: Partial<RetentionSettings>): Promise<RetentionSettings> {
const response = await apiClient.patch<RetentionSettings>('/assistant/retention', data)
return response.data
},
}
export default assistantChatApi

View File

@@ -1,5 +1,5 @@
import apiClient from './client'
import type { Token, User, UserCreate, UserLogin } from '@/types'
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types'
export const authApi = {
async register(data: UserCreate): Promise<User> {
@@ -53,6 +53,24 @@ 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 getVerificationStatus(): Promise<{ enabled: boolean }> {
const response = await apiClient.get<{ enabled: boolean }>('/auth/email/verification-status')
return response.data
},
async sendVerificationEmail(): Promise<void> {
await apiClient.post('/auth/email/send-verification')
},
async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token })
},
}
export default authApi

View File

@@ -0,0 +1,30 @@
import apiClient from './client'
import type {
CopilotStartRequest,
CopilotStartResponse,
CopilotMessageRequest,
CopilotMessageResponse,
CopilotConversation,
} from '@/types/copilot'
export const copilotApi = {
async startConversation(data: CopilotStartRequest): Promise<CopilotStartResponse> {
const response = await apiClient.post<CopilotStartResponse>('/copilot/conversations', data)
return response.data
},
async sendMessage(conversationId: string, data: CopilotMessageRequest): Promise<CopilotMessageResponse> {
const response = await apiClient.post<CopilotMessageResponse>(
`/copilot/conversations/${conversationId}/messages`,
data
)
return response.data
},
async getConversation(conversationId: string): Promise<CopilotConversation> {
const response = await apiClient.get<CopilotConversation>(`/copilot/conversations/${conversationId}`)
return response.data
},
}
export default copilotApi

View File

@@ -18,3 +18,5 @@ export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
export { default as feedbackApi } from './feedback'
export { default as aiBuilderApi } from './aiBuilder'
export { default as aiChatApi } from './aiChat'
export { copilotApi } from './copilot'
export { assistantChatApi } from './assistantChat'

View File

@@ -1,8 +1,8 @@
<svg viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="resolutionflow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#818cf8"/>
<stop offset="100%" stop-color="#a78bfa"/>
<stop offset="0%" stop-color="#06b6d4"/>
<stop offset="100%" stop-color="#22d3ee"/>
</linearGradient>
</defs>
<!-- Input circles (choices) -->

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,8 +1,8 @@
<svg viewBox="0 0 320 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="rf-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#818cf8"/>
<stop offset="100%" stop-color="#a78bfa"/>
<stop offset="0%" stop-color="#06b6d4"/>
<stop offset="100%" stop-color="#22d3ee"/>
</linearGradient>
</defs>
@@ -26,7 +26,7 @@
</g>
<!-- Text -->
<text x="72" y="50" font-family="'Plus Jakarta Sans', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
<text x="72" y="50" font-family="'Bricolage Grotesque', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
<tspan fill="#ffffff">Resolution</tspan><tspan fill="url(#rf-gradient)">Flow</tspan>
</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,8 +1,8 @@
<svg viewBox="0 0 320 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="rf-gradient-tag" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#818cf8"/>
<stop offset="100%" stop-color="#a78bfa"/>
<stop offset="0%" stop-color="#06b6d4"/>
<stop offset="100%" stop-color="#22d3ee"/>
</linearGradient>
</defs>
@@ -26,12 +26,12 @@
</g>
<!-- Text -->
<text x="72" y="50" font-family="'Plus Jakarta Sans', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
<text x="72" y="50" font-family="'Bricolage Grotesque', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
<tspan fill="#ffffff">Resolution</tspan><tspan fill="url(#rf-gradient-tag)">Flow</tspan>
</text>
<!-- Tagline -->
<text x="72" y="75" font-family="'Plus Jakarta Sans', 'Segoe UI', sans-serif" font-size="13" font-weight="500" fill="url(#rf-gradient-tag)">
<text x="72" y="75" font-family="'Bricolage Grotesque', 'Segoe UI', sans-serif" font-size="13" font-weight="500" fill="url(#rf-gradient-tag)">
From issue to resolution, documented.
</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Loader2, AlertTriangle } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
interface Props {
onClose: () => void
}
export function DeleteAccountModal({ onClose }: Props) {
const logout = useAuthStore((s) => s.logout)
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleDelete = async (e: React.FormEvent) => {
e.preventDefault()
if (!password) return
setIsSubmitting(true)
setError(null)
try {
await accountsApi.deleteAccount(password)
await logout()
navigate('/login')
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setError(axiosErr.response?.data?.detail ?? 'Failed to delete account')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="glass-card-static w-full max-w-md p-6">
<div className="flex items-center gap-2 text-rose-500 mb-4">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold font-heading text-foreground">Delete Account</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
This action is <strong className="text-rose-400">permanent</strong>. Your account, data,
and all associated flows will be permanently deleted.
</p>
<form onSubmit={handleDelete} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground">Confirm Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className={cn(
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
'text-foreground focus:border-primary focus:outline-none'
)}
/>
</div>
{error && <p className="text-sm text-rose-500">{error}</p>}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
)}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !password}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-semibold',
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
)}
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete Forever'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { useState } from 'react'
import { Loader2, AlertTriangle } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface Props {
accountName: string
onClose: () => void
}
export function LeaveAccountModal({ accountName, onClose }: Props) {
const fetchUser = useAuthStore((s) => s.fetchUser)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleLeave = async () => {
setIsSubmitting(true)
try {
await accountsApi.leaveAccount()
toast.success('You have left the account')
await fetchUser()
onClose()
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail ?? 'Failed to leave account')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="glass-card-static w-full max-w-md p-6">
<div className="flex items-center gap-2 text-amber-400 mb-4">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold font-heading text-foreground">Leave Account</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
Are you sure you want to leave <strong className="text-foreground">{accountName}</strong>?
A new personal account will be created for you.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
)}
>
Cancel
</button>
<button
onClick={handleLeave}
disabled={isSubmitting}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-semibold',
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
)}
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Leave Account'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { Loader2, AlertTriangle } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import { useAuthStore } from '@/store/authStore'
import type { AccountMember } from '@/types'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface Props {
members: AccountMember[]
onClose: () => void
onTransferred: () => void
}
export function TransferOwnershipModal({ members, onClose, onTransferred }: Props) {
const user = useAuthStore((s) => s.user)
const nonOwnerMembers = members.filter((m) => m.id !== user?.id)
const [targetUserId, setTargetUserId] = useState(nonOwnerMembers[0]?.id ?? '')
const [password, setPassword] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!targetUserId || !password) return
setIsSubmitting(true)
setError(null)
try {
await accountsApi.transferOwnership(password, targetUserId)
toast.success('Ownership transferred')
onTransferred()
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setError(axiosErr.response?.data?.detail ?? 'Transfer failed')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="glass-card-static w-full max-w-md p-6">
<div className="flex items-center gap-2 text-amber-400 mb-4">
<AlertTriangle className="h-5 w-5" />
<h2 className="text-lg font-semibold font-heading text-foreground">Transfer Ownership</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
This will make the selected member the new account owner. You will become an engineer.
</p>
{nonOwnerMembers.length === 0 ? (
<p className="text-sm text-muted-foreground">No other members to transfer to.</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground">New Owner</label>
<select
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
className={cn(
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
'text-foreground focus:border-primary focus:outline-none'
)}
>
{nonOwnerMembers.map((m) => (
<option key={m.id} value={m.id}>{m.name} ({m.email})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground">Your Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className={cn(
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
'text-foreground focus:border-primary focus:outline-none'
)}
/>
</div>
{error && <p className="text-sm text-rose-500">{error}</p>}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
)}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !password}
className={cn(
'rounded-[10px] px-4 py-2 text-sm font-semibold',
'bg-amber-500 text-[#101114] hover:bg-amber-400',
'disabled:opacity-50'
)}
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Transfer'}
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { Sparkles, User } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { SuggestedFlowCard } from './SuggestedFlowCard'
import type { SuggestedFlow } from '@/types/copilot'
interface ChatMessageProps {
role: 'user' | 'assistant'
content: string
suggestedFlows?: SuggestedFlow[]
}
export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps) {
return (
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
{/* Avatar */}
<div
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
role === 'assistant'
? 'bg-primary/15 text-primary'
: 'bg-[rgba(255,255,255,0.08)] text-muted-foreground'
}`}
>
{role === 'assistant' ? <Sparkles size={14} /> : <User size={14} />}
</div>
{/* Content */}
<div className={`max-w-[80%] space-y-2 ${role === 'user' ? 'text-right' : ''}`}>
<div
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
role === 'user'
? 'bg-primary/15 text-foreground'
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
}`}
>
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
</div>
{/* Suggested flows (assistant only) */}
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
<div className="space-y-1.5">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Related Flows
</span>
{suggestedFlows.map(flow => (
<SuggestedFlowCard key={flow.tree_id} flow={flow} />
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import { Plus, Pin, Trash2, MessageSquare } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ChatListItem } from '@/types/assistant-chat'
interface ChatSidebarProps {
chats: ChatListItem[]
activeChatId: string | null
onSelectChat: (id: string) => void
onNewChat: () => void
onDeleteChat: (id: string) => void
onTogglePin: (id: string, pinned: boolean) => void
}
export function ChatSidebar({
chats,
activeChatId,
onSelectChat,
onNewChat,
onDeleteChat,
onTogglePin,
}: ChatSidebarProps) {
const pinnedChats = chats.filter(c => c.pinned)
const unpinnedChats = chats.filter(c => !c.pinned)
return (
<div
className="w-72 shrink-0 flex flex-col border-r h-full"
style={{ borderColor: 'var(--glass-border)' }}
>
{/* Header */}
<div className="px-4 py-3 border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
<button
onClick={onNewChat}
className="w-full flex items-center justify-center gap-2 bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-4 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
>
<Plus size={16} />
New Chat
</button>
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto py-2">
{pinnedChats.length > 0 && (
<div className="px-3 mb-1">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Pinned
</span>
</div>
)}
{pinnedChats.map(chat => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === activeChatId}
onSelect={() => onSelectChat(chat.id)}
onDelete={() => onDeleteChat(chat.id)}
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
/>
))}
{pinnedChats.length > 0 && unpinnedChats.length > 0 && (
<div className="mx-3 my-2 border-b" style={{ borderColor: 'var(--glass-border)' }} />
)}
{unpinnedChats.map(chat => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === activeChatId}
onSelect={() => onSelectChat(chat.id)}
onDelete={() => onDeleteChat(chat.id)}
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
/>
))}
{chats.length === 0 && (
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
No conversations yet
</div>
)}
</div>
</div>
)
}
function ChatItem({
chat,
isActive,
onSelect,
onDelete,
onTogglePin,
}: {
chat: ChatListItem
isActive: boolean
onSelect: () => void
onDelete: () => void
onTogglePin: () => void
}) {
return (
<div
onClick={onSelect}
className={cn(
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
isActive
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:bg-[rgba(255,255,255,0.04)] hover:text-foreground'
)}
>
<MessageSquare size={14} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
<div className="text-[0.6875rem] text-muted-foreground">
{chat.message_count} messages
</div>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); onTogglePin() }}
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)]"
title={chat.pinned ? 'Unpin' : 'Pin'}
>
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
</button>
<button
onClick={e => { e.stopPropagation(); onDelete() }}
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)] text-muted-foreground hover:text-rose-400"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { useNavigate } from 'react-router-dom'
import { Box, ArrowRight } from 'lucide-react'
import { getTreeNavigatePath } from '@/lib/routing'
import type { SuggestedFlow } from '@/types/copilot'
interface SuggestedFlowCardProps {
flow: SuggestedFlow
}
export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
const navigate = useNavigate()
const handleClick = () => {
const path = getTreeNavigatePath(flow.tree_id, flow.tree_type)
navigate(path)
}
return (
<button
onClick={handleClick}
className="w-full text-left glass-card-static p-3 rounded-xl hover:border-[rgba(255,255,255,0.12)] transition-colors group"
>
<div className="flex items-start gap-2">
<Box size={14} className="text-primary mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[0.8125rem] font-medium text-foreground truncate">
{flow.tree_name}
</span>
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
{flow.tree_type}
</span>
</div>
<p className="text-[0.75rem] text-muted-foreground mt-0.5 line-clamp-2">
{flow.relevance_snippet}
</p>
</div>
<ArrowRight size={14} className="text-muted-foreground group-hover:text-primary transition-colors shrink-0 mt-0.5" />
</div>
</button>
)
}

View File

@@ -5,42 +5,37 @@ interface BrandLogoProps {
className?: string
}
/**
* ResolutionFlow brand logo icon — white monochrome.
* sm (32x32) for header/navbar, lg (80x80) for login/register pages.
*/
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
const sizeClasses = size === 'sm' ? 'h-8 w-8' : 'h-20 w-20'
const strokeBase = size === 'sm' ? 1 : 2
const strokeThick = size === 'sm' ? 1.25 : 2.5
const dashArray = size === 'sm' ? '1 1.5' : '2 3'
const nodeR = size === 'sm' ? { outer: 2.5, inner: 2.75 } : { outer: 5, inner: 5.5 }
const hubR = size === 'sm' ? { glow: 5, solid: 3.5 } : { glow: 10, solid: 7 }
const vb = size === 'sm' ? '0 0 40 40' : '0 0 80 80'
const s = size === 'sm' ? 1 : 2
const gradId = size === 'sm' ? 'logoGradSm' : 'logoGradLg'
const gradEnd = String(40 * (size === 'sm' ? 1 : 2))
return (
<svg viewBox={vb} fill="none" className={cn(sizeClasses, className)}>
{/* Input nodes */}
<circle cx={5 * s} cy={7 * s} r={nodeR.outer} fill="white" opacity="0.35" />
<circle cx={5 * s} cy={15 * s} r={nodeR.inner} fill="white" opacity="0.5" />
<circle cx={5 * s} cy={25 * s} r={nodeR.inner} fill="white" opacity="0.5" />
<circle cx={5 * s} cy={33 * s} r={nodeR.outer} fill="white" opacity="0.35" />
{/* Connecting lines */}
<path d={`M${7.5 * s} ${7 * s}L${14 * s} ${17 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.45" />
<path d={`M${7.75 * s} ${15 * s}L${14 * s} ${19 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" opacity="0.6" />
<path d={`M${7.75 * s} ${25 * s}L${14 * s} ${21 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" opacity="0.6" />
<path d={`M${7.5 * s} ${33 * s}L${14 * s} ${23 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.45" />
{/* Central hub */}
<circle cx={18 * s} cy={20 * s} r={hubR.glow} fill="white" opacity="0.15" />
<circle cx={18 * s} cy={20 * s} r={hubR.solid} fill="white" />
{/* Output arrow */}
<path d={`M${21.5 * s} ${20 * s}H${35 * s}M${35 * s} ${20 * s}L${30 * s} ${15 * s}M${35 * s} ${20 * s}L${30 * s} ${25 * s}`} stroke="white" strokeWidth={strokeThick} strokeLinecap="round" strokeLinejoin="round" />
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2={gradEnd} y2={gradEnd} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
<circle cx={5 * s} cy={7 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
<circle cx={5 * s} cy={15 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
<circle cx={5 * s} cy={25 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
<circle cx={5 * s} cy={33 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
<path d={`M${7.5 * s} ${7 * s}L${14 * s} ${17 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
<path d={`M${7.75 * s} ${15 * s}L${14 * s} ${19 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
<path d={`M${7.75 * s} ${25 * s}L${14 * s} ${21 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
<path d={`M${7.5 * s} ${33 * s}L${14 * s} ${23 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
<circle cx={18 * s} cy={20 * s} r={hubR.glow} fill={`url(#${gradId})`} opacity="0.15" />
<circle cx={18 * s} cy={20 * s} r={hubR.solid} fill={`url(#${gradId})`} opacity="0.9" />
<path d={`M${21.5 * s} ${20 * s}H${35 * s}M${35 * s} ${20 * s}L${30 * s} ${15 * s}M${35 * s} ${20 * s}L${30 * s} ${25 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeThick} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}

View File

@@ -5,20 +5,17 @@ interface BrandWordmarkProps {
className?: string
}
/**
* ResolutionFlow wordmark — clean white text.
* sm for header/navbar, lg for login/register pages.
*/
export function BrandWordmark({ size = 'sm', className }: BrandWordmarkProps) {
return (
<span
className={cn(
'font-semibold tracking-tight text-white',
'font-heading font-bold tracking-tight',
size === 'sm' ? 'text-xl' : 'text-3xl',
className
)}
>
ResolutionFlow
<span className="text-foreground">Resolution</span>
<span className="text-gradient-brand">Flow</span>
</span>
)
}

View File

@@ -1,14 +1,44 @@
import { useEffect } from 'react'
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'
import { cn } from '@/lib/utils'
function isChunkLoadError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const msg = error.message.toLowerCase()
return (
msg.includes('failed to fetch dynamically imported module') ||
msg.includes('importing a module script failed') ||
msg.includes('loading chunk') ||
msg.includes('loading css chunk')
)
}
const RELOAD_KEY = 'rf_chunk_reload'
export function RouteError() {
const error = useRouteError()
const navigate = useNavigate()
// Auto-reload once on chunk load failures (stale deploy)
useEffect(() => {
if (isChunkLoadError(error)) {
const lastReload = sessionStorage.getItem(RELOAD_KEY)
const now = Date.now()
// Only auto-reload if we haven't reloaded in the last 10 seconds (prevent loops)
if (!lastReload || now - Number(lastReload) > 10_000) {
sessionStorage.setItem(RELOAD_KEY, String(now))
window.location.reload()
}
}
}, [error])
let errorMessage = 'An unexpected error occurred'
let errorDetails = ''
if (isRouteErrorResponse(error)) {
if (isChunkLoadError(error)) {
errorMessage = 'App Updated'
errorDetails = 'A new version was deployed. Please refresh the page.'
} else if (isRouteErrorResponse(error)) {
errorMessage = error.status === 404 ? 'Page not found' : `Error ${error.status}`
errorDetails = error.statusText || ''
} else if (error instanceof Error) {

View File

@@ -0,0 +1,180 @@
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'
interface CopilotPanelProps {
isOpen: boolean
onClose: () => void
treeId: string
sessionId?: string
currentNodeId?: string
}
export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId }: CopilotPanelProps) {
const [conversationId, setConversationId] = useState<string | null>(null)
const [messages, setMessages] = useState<CopilotMessage[]>([])
const [suggestedFlows, setSuggestedFlows] = useState<SuggestedFlow[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [initializing, setInitializing] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const startConversation = useCallback(async () => {
setInitializing(true)
try {
const response = await copilotApi.startConversation({
tree_id: treeId,
session_id: sessionId,
current_node_id: currentNodeId,
})
setConversationId(response.conversation_id)
setMessages([{ role: 'assistant', content: response.greeting }])
} catch {
setMessages([{ role: 'assistant', content: 'Failed to start copilot. Please try again.' }])
} 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
const userMessage = input.trim()
setInput('')
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true)
try {
const response = await copilotApi.sendMessage(conversationId, {
message: userMessage,
current_node_id: currentNodeId,
})
setMessages(prev => [...prev, { role: 'assistant', content: response.content }])
if (response.suggested_flows.length > 0) {
setSuggestedFlows(response.suggested_flows)
}
} catch {
setMessages(prev => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }])
} finally {
setLoading(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (!isOpen) return null
return (
<div
className="fixed right-0 top-0 bottom-0 z-50 flex flex-col border-l"
style={{
width: '400px',
background: 'rgba(16, 17, 20, 0.95)',
backdropFilter: 'var(--glass-blur)',
WebkitBackdropFilter: 'var(--glass-blur)',
borderColor: 'var(--glass-border)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-primary" />
<span className="text-sm font-semibold text-foreground">AI Copilot</span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
msg.role === 'user'
? 'bg-primary/15 text-foreground'
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
}`}
>
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-xl px-3.5 py-2.5">
<Loader2 size={16} className="animate-spin text-primary" />
</div>
</div>
)}
{/* Suggested flows */}
{suggestedFlows.length > 0 && (
<div className="space-y-2 pt-2">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Related Flows
</span>
{suggestedFlows.map(flow => (
<SuggestedFlowCard key={flow.tree_id} flow={flow} />
))}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<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}
placeholder="Ask about this step..."
rows={1}
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
disabled={loading || initializing}
/>
<button
onClick={handleSend}
disabled={!input.trim() || loading || initializing}
className="bg-gradient-brand text-[#101114] p-2.5 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
>
<Send size={16} />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { MessageCircle } from 'lucide-react'
interface CopilotToggleProps {
isOpen: boolean
onToggle: () => void
}
export function CopilotToggle({ isOpen, onToggle }: CopilotToggleProps) {
if (isOpen) return null
return (
<button
onClick={onToggle}
className="fixed bottom-6 right-6 z-40 bg-gradient-brand text-[#101114] p-3.5 rounded-full shadow-lg shadow-primary/30 hover:opacity-90 active:scale-[0.97] transition-all"
title="Open AI Copilot"
>
<MessageCircle size={22} />
</button>
)
}

View File

@@ -0,0 +1,65 @@
import { Link } from 'react-router-dom'
import { getTreeNavigatePath } from '@/lib/routing'
interface OpenSession {
id: string
treeName: string
treeId: string
treeType?: string
stepNumber?: number
totalSteps?: number
timeAgo: string
}
interface OpenSessionsProps {
sessions: OpenSession[]
}
export function OpenSessions({ sessions }: OpenSessionsProps) {
return (
<div className="glass-card-static flex flex-col h-full">
<div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
<h3 className="font-heading text-sm font-bold text-foreground">My Open Sessions</h3>
<Link to="/sessions" className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors">
View All
</Link>
</div>
<div className="flex-1 flex flex-col">
{sessions.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground">No open sessions</p>
</div>
) : (
sessions.map((session, i) => (
<div
key={session.id}
className="flex items-center gap-3 px-5 py-3"
style={{
borderBottom: i < sessions.length - 1 ? '1px solid var(--glass-border)' : undefined,
}}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-400" />
<div className="flex-1 min-w-0">
<div className="text-sm text-foreground truncate">{session.treeName}</div>
<div className="text-[0.6875rem] text-muted-foreground">
{session.stepNumber && session.totalSteps
? `Step ${session.stepNumber} of ${session.totalSteps}`
: 'In progress'}
<span className="mx-1.5 text-[hsl(var(--text-dimmed))]">&middot;</span>
<span className="font-label text-[0.625rem]">{session.timeAgo}</span>
</div>
</div>
<Link
to={getTreeNavigatePath(session.treeId, session.treeType)}
state={{ sessionId: session.id }}
className="shrink-0 rounded-lg bg-gradient-brand px-3 py-1 text-[0.6875rem] font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
Resume
</Link>
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom'
import { Plus, Play, BookOpen, UserPlus } from 'lucide-react'
const ACTIONS = [
{ icon: Plus, label: 'New Flow', description: 'Create a new flow', href: '/trees/new', color: '#06b6d4' },
{ icon: Play, label: 'Resume Session', description: 'Continue where you left off', href: '/sessions', color: '#34d399' },
{ icon: BookOpen, label: 'Browse Library', description: 'Explore step library', href: '/step-library', color: '#fbbf24' },
{ icon: UserPlus, label: 'Invite Team', description: 'Add team members', href: '/account', color: '#06b6d4' },
] as const
export function QuickActions() {
const navigate = useNavigate()
return (
<div className="glass-card-static flex flex-col h-full">
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
<h3 className="font-heading text-sm font-bold text-foreground">Quick Actions</h3>
</div>
<div className="flex-1 flex flex-col justify-between p-3 gap-2">
{ACTIONS.map(({ icon: Icon, label, description, href, color }) => (
<button
key={label}
onClick={() => navigate(href)}
className="glass-card flex items-center gap-3 px-4 py-3 text-left"
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
style={{ background: `${color}15` }}
>
<Icon size={18} style={{ color }} />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-[0.6875rem] text-muted-foreground truncate">{description}</div>
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -18,15 +18,15 @@ export function QuickStats({ stats }: QuickStatsProps) {
{stats.map((stat, i) => (
<div
key={stat.label}
className="fade-in rounded-xl border border-border bg-card p-4 transition-colors hover:border-border/80"
className={cn('glass-card p-4 fade-in', i === 0 && 'active-glow')}
style={{ animationDelay: `${50 + i * 30}ms` }}
>
<p className="font-label text-[0.6875rem] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<p className="font-label text-[0.625rem] font-medium uppercase tracking-[0.1em] text-muted-foreground">
{stat.label}
</p>
<p
className={cn(
'mt-1 font-heading text-2xl font-bold tracking-tight',
'mt-1 font-heading text-2xl font-extrabold tracking-tight',
stat.gradient && 'text-gradient-brand',
stat.color
)}

View File

@@ -0,0 +1,58 @@
import type { LucideIcon } from 'lucide-react'
import { GitBranch, Play, CheckCircle, FileText, Edit } from 'lucide-react'
interface ActivityItem {
id: string
icon: LucideIcon
iconColor: string
iconBg: string
description: string
timestamp: string
}
interface RecentActivityProps {
activities?: ActivityItem[]
}
const DEFAULT_ACTIVITIES: ActivityItem[] = [
{ id: '1', icon: Play, iconColor: '#34d399', iconBg: 'rgba(52, 211, 153, 0.1)', description: 'Started VPN Connectivity Triage session', timestamp: '2 min ago' },
{ id: '2', icon: CheckCircle, iconColor: '#06b6d4', iconBg: 'rgba(6, 182, 212, 0.1)', description: 'Completed M365 License Provisioning', timestamp: '15 min ago' },
{ id: '3', icon: Edit, iconColor: '#fbbf24', iconBg: 'rgba(251, 191, 36, 0.1)', description: 'Updated Printer Troubleshooting flow', timestamp: '1 hr ago' },
{ id: '4', icon: GitBranch, iconColor: '#06b6d4', iconBg: 'rgba(6, 182, 212, 0.1)', description: 'Created new DNS Resolution flow', timestamp: '3 hr ago' },
{ id: '5', icon: FileText, iconColor: '#8891a0', iconBg: 'rgba(136, 145, 160, 0.1)', description: 'Exported session report #TK-4821', timestamp: 'Yesterday' },
]
export function RecentActivity({ activities = DEFAULT_ACTIVITIES }: RecentActivityProps) {
return (
<div className="glass-card-static">
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
<h3 className="font-heading text-sm font-bold text-foreground">Recent Activity</h3>
</div>
<div>
{activities.map((item, i) => (
<div
key={item.id}
className="flex items-start gap-3 px-5 py-3 fade-in"
style={{
animationDelay: `${750 + i * 40}ms`,
borderBottom: i < activities.length - 1 ? '1px solid var(--glass-border)' : undefined,
}}
>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px]"
style={{ background: item.iconBg }}
>
<item.icon size={16} style={{ color: item.iconColor }} />
</span>
<div className="flex-1 min-w-0 pt-0.5">
<p className="text-sm text-foreground">{item.description}</p>
</div>
<span className="shrink-0 font-label text-[0.625rem] text-muted-foreground pt-1">
{item.timestamp}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -21,8 +21,8 @@ export function SessionsPanel({ sessions, delay = 200 }: SessionsPanelProps) {
if (sessions.length === 0) return null
return (
<div className="fade-in rounded-xl border border-border bg-card" style={{ animationDelay: `${delay}ms` }}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="glass-card-static fade-in" style={{ animationDelay: `${delay}ms` }}>
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
<h3 className="font-heading text-sm font-semibold text-foreground">Recent Sessions</h3>
<Link to="/sessions" className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors">
View All

View File

@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import { Calendar } from 'lucide-react'
interface CalendarEvent {
id: string
title: string
time: string
type: 'default' | 'maintenance'
}
interface WeeklyCalendarProps {
events?: Record<string, CalendarEvent[]>
}
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
function getWeekDays(): { label: string; date: Date; dateStr: string; isToday: boolean }[] {
const now = new Date()
const day = now.getDay()
const mondayOffset = day === 0 ? 6 : day - 1
const monday = new Date(now)
monday.setDate(now.getDate() - mondayOffset)
return DAY_NAMES.map((label, i) => {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
const dateStr = d.toISOString().split('T')[0]
const isToday = d.toDateString() === now.toDateString()
return { label, date: d, dateStr, isToday }
})
}
export function WeeklyCalendar({ events = {} }: WeeklyCalendarProps) {
const days = useMemo(() => getWeekDays(), [])
return (
<div className="glass-card-static flex flex-col h-full">
<div className="flex items-center gap-2 px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
<Calendar size={16} className="text-muted-foreground" />
<h3 className="font-heading text-sm font-bold text-foreground">This Week</h3>
</div>
<div className="flex flex-1 min-h-0">
{days.map((day, i) => {
const dayEvents = events[day.dateStr] || []
return (
<div
key={day.dateStr}
className="flex-1 flex flex-col min-h-0"
style={{
borderRight: i < 4 ? '1px solid var(--glass-border)' : undefined,
}}
>
<div
className="px-2 py-2 text-center"
style={{
borderBottom: day.isToday ? '2px solid #06b6d4' : '1px solid var(--glass-border)',
}}
>
<span className={`font-label text-[0.625rem] uppercase tracking-[0.1em] ${day.isToday ? 'text-cyan-400' : 'text-muted-foreground'}`}>
{day.label}
</span>
<div className={`text-sm font-heading font-bold ${day.isToday ? 'text-foreground' : 'text-muted-foreground'}`}>
{day.date.getDate()}
</div>
</div>
<div className="flex-1 overflow-y-auto p-1.5 space-y-1">
{dayEvents.length === 0 ? (
<p className="text-[0.625rem] text-[hsl(var(--text-dimmed))] text-center py-2">No events</p>
) : (
dayEvents.map(event => (
<div
key={event.id}
className="rounded-md px-2 py-1.5 text-[0.6875rem] cursor-pointer hover:bg-accent/30 transition-colors"
style={{
borderLeft: `3px solid ${event.type === 'maintenance' ? '#fbbf24' : '#06b6d4'}`,
background: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="font-medium text-foreground truncate">{event.title}</div>
<div className="font-label text-[0.5625rem] text-muted-foreground">{event.time}</div>
</div>
))
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { TopBar } from './TopBar'
import { Sidebar } from './Sidebar'
import { EmailVerificationBanner } from './EmailVerificationBanner'
import { cn } from '@/lib/utils'
export function AppLayout() {
@@ -59,7 +60,34 @@ export function AppLayout() {
]
return (
<div className={cn('app-shell', sidebarCollapsed && 'app-shell--collapsed')}>
<>
{/* Atmosphere orbs — ambient light behind glass */}
<div
className="pointer-events-none fixed z-0"
style={{
top: '-120px',
right: '-80px',
width: '600px',
height: '600px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, rgba(6, 182, 212, 0.04) 40%, transparent 70%)',
filter: 'blur(60px)',
}}
/>
<div
className="pointer-events-none fixed z-0"
style={{
bottom: '-100px',
left: '-60px',
width: '500px',
height: '500px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0.02) 40%, transparent 70%)',
filter: 'blur(50px)',
}}
/>
<div className={cn('app-shell relative z-[1]', sidebarCollapsed && 'app-shell--collapsed')}>
{/* Top Bar - spans full width */}
<TopBar />
@@ -156,9 +184,11 @@ export function AppLayout() {
{/* Main Content */}
<main className="main-content overflow-y-auto">
<EmailVerificationBanner />
<Outlet />
</main>
</div>
</>
)
}

View File

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

View File

@@ -25,7 +25,7 @@ const ACTIONS: QuickAction[] = [
{ id: 'new-project', icon: Plus, label: 'New Project', description: 'Create a step-by-step project', path: '/flows/new', color: '#8b5cf6' },
{ id: 'sessions', icon: Play, label: 'View Sessions', description: 'See active and recent sessions', path: '/sessions', color: '#f59e0b' },
{ id: 'step-library', icon: Bookmark, label: 'Step Library', description: 'Browse reusable steps', path: '/step-library', color: '#10b981' },
{ id: 'exports', icon: FileText, label: 'Exports & Shares', description: 'View shared session exports', path: '/shares', color: '#6366f1' },
{ id: 'exports', icon: FileText, label: 'Exports & Shares', description: 'View shared session exports', path: '/shares', color: '#06b6d4' },
{ id: 'team', icon: Users, label: 'Team Settings', description: 'Manage team members and roles', path: '/account', color: '#ec4899' },
]

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles } 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'
@@ -63,7 +63,13 @@ export function Sidebar() {
return (
<nav
className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]"
className="sidebar flex flex-col border-r"
style={{
background: 'rgba(16, 17, 20, 0.5)',
backdropFilter: 'var(--glass-blur-light)',
WebkitBackdropFilter: 'var(--glass-blur-light)',
borderColor: 'var(--glass-border)',
}}
onWheel={handleSidebarWheel}
>
{sidebarCollapsed ? (
@@ -76,8 +82,10 @@ export function Sidebar() {
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
<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>
</>
@@ -86,7 +94,7 @@ export function Sidebar() {
{/* Pinned Flows */}
<PinnedFlowsSection flows={pinnedItems} onUnpin={unpinFlow} />
<div className="border-b border-[hsl(var(--border-subtle))]" />
<div style={{ borderBottom: '1px solid var(--glass-border)' }} />
{/* Primary Navigation */}
<div className="px-3 py-2 space-y-0.5">
@@ -107,6 +115,7 @@ export function Sidebar() {
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
<NavItem href="/shares" icon={FileText} label="Exports" />
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
</div>
@@ -117,12 +126,16 @@ export function Sidebar() {
<div className="flex-1" />
{/* Footer */}
<div className={cn(
"border-t border-[hsl(var(--border-subtle))]",
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
)}>
<div
className={cn(
"border-t",
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
)}
style={{ borderColor: 'var(--glass-border)' }}
>
{!sidebarCollapsed && (
<>
<NavItem href="/guides" icon={BookOpen} label="User Guides" />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" />
<NavItem href="/account" icon={Settings} label="Account" />
</>

View File

@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Search, Zap, LogOut, Shield, Settings } from 'lucide-react'
import { Search, Zap, LogOut, Shield, Settings, HelpCircle } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { BrandLogo } from '@/components/common/BrandLogo'
@@ -54,15 +54,21 @@ export function TopBar() {
return (
<>
<header className="topbar flex items-center gap-4 border-b border-border bg-background px-4">
<header
className="topbar relative z-10 flex items-center gap-4 border-b px-4"
style={{
background: 'rgba(16, 17, 20, 0.6)',
backdropFilter: 'var(--glass-blur-strong)',
WebkitBackdropFilter: 'var(--glass-blur-strong)',
borderColor: 'var(--glass-border)',
}}
>
{/* Logo area */}
<Link
to="/"
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-brand">
<BrandLogo size="sm" className="h-4 w-4" />
</div>
<BrandLogo size="sm" className="h-7 w-7 shrink-0" />
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap">
<span className="text-foreground">Resolution</span>
<span className="text-gradient-brand">Flow</span>
@@ -99,13 +105,20 @@ 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 */}
<div className="relative ml-2" ref={menuRef}>
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-brand text-xs font-heading font-bold text-white hover:opacity-90 transition-opacity"
className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-gradient-brand text-xs font-heading font-bold text-primary-foreground hover:opacity-90 transition-opacity"
title={user?.name || user?.email || 'User'}
>
{initials}

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

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

View File

@@ -4,35 +4,52 @@
@layer base {
:root {
/* ResolutionFlow Dark Theme — Purple Gradient Accents */
--background: 240 10% 3.9%;
--foreground: 0 0% 100%;
--card: 240 10% 9.4%;
--card-foreground: 0 0% 100%;
--popover: 240 10% 9.4%;
--popover-foreground: 0 0% 100%;
--primary: 243 75% 59%;
--primary-foreground: 0 0% 100%;
--secondary: 240 5.9% 15%;
--secondary-foreground: 0 0% 100%;
--muted: 240 5.9% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 5.9% 15%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 100%;
--border: 240 5.9% 15%;
--input: 240 5.9% 15%;
--ring: 243 75% 59%;
/* ResolutionFlow Dark Theme — Slate & Ice Modern */
--background: 228 12% 7%;
--foreground: 210 40% 98%;
--card: 220 10% 10%;
--card-foreground: 210 40% 98%;
--popover: 220 10% 10%;
--popover-foreground: 210 40% 98%;
--primary: 187 72% 43%;
--primary-foreground: 228 12% 7%;
--secondary: 220 8% 14%;
--secondary-foreground: 210 40% 98%;
--muted: 220 8% 14%;
--muted-foreground: 215 10% 58%;
--accent: 220 8% 14%;
--accent-foreground: 210 40% 98%;
--destructive: 350 81% 55%;
--destructive-foreground: 210 40% 98%;
--border: 220 8% 14%;
--input: 220 8% 14%;
--ring: 187 72% 43%;
--radius: 0.75rem;
/* App Shell tokens */
--sidebar-w: 260px;
--sidebar-bg: 240 10% 4.5%;
--sidebar-hover: 240 6% 12%;
--sidebar-active: 243 75% 59% / 0.08;
--border-subtle: 240 6% 12%;
--text-dimmed: 240 4% 24%;
--sidebar-bg: 228 12% 6%;
--sidebar-hover: 220 8% 14%;
--sidebar-active: 187 72% 43% / 0.10;
--border-subtle: 220 8% 12%;
--text-dimmed: 218 10% 39%;
/* Glass system */
--glass-bg: rgba(24, 26, 31, 0.55);
--glass-bg-hover: rgba(24, 26, 31, 0.7);
--glass-border: rgba(255, 255, 255, 0.06);
--glass-border-hover: rgba(255, 255, 255, 0.12);
--glass-blur: blur(16px);
--glass-blur-strong: blur(20px);
--glass-blur-light: blur(12px);
/* Shadow system */
--shadow-float: 0 8px 32px rgba(0, 0, 0, 0.3);
--shadow-float-hover: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
--shadow-cyan-glow: 0 8px 32px rgba(6, 182, 212, 0.08);
/* Easing */
--ease-out-smooth: cubic-bezier(0.4, 0, 0.2, 1);
}
}
@@ -59,11 +76,11 @@
body {
@apply bg-background text-foreground;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
@@ -140,6 +157,30 @@
to { opacity: 1; transform: scale(1); }
}
@keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeInRight {
from { transform: translateX(30px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes breatheGlow {
from { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 20px rgba(6, 182, 212, 0.04); }
to { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 30px rgba(6, 182, 212, 0.12); }
}
@keyframes bellWobble {
0% { transform: rotate(0deg); }
20% { transform: rotate(8deg); }
40% { transform: rotate(-6deg); }
60% { transform: rotate(4deg); }
80% { transform: rotate(-2deg); }
100% { transform: rotate(0deg); }
}
@layer utilities {
.animate-fade-in {
animation: fade-in 200ms ease-out;
@@ -171,30 +212,37 @@
@apply bg-gradient-brand bg-clip-text text-transparent;
}
/* ── Legacy glass-card utilities (preserved for backward compatibility) ── */
/* New components should use bg-card border-border rounded-xl instead */
/* Glass card — interactive with hover lift */
.glass-card {
background: linear-gradient(135deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
border-radius: 16px;
box-shadow: var(--shadow-float);
transition: transform 200ms var(--ease-out-smooth),
border-color 200ms var(--ease-out-smooth),
box-shadow 200ms var(--ease-out-smooth);
}
.glass-card:hover {
transform: scale(1.02);
border-color: var(--glass-border-hover);
box-shadow: var(--shadow-float-hover);
}
.glass-card-hover {
background: linear-gradient(135deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.02) 100%);
border-color: rgba(255, 255, 255, 0.12);
/* Glass card — static, no hover transform */
.glass-card-static {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border);
border-radius: 16px;
box-shadow: var(--shadow-float);
}
.glass-card-glow {
background: linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 40px rgba(255, 255, 255, 0.1);
}
.glass-stat {
background: rgba(20, 20, 25, 0.5);
border: 1px solid rgba(255, 255, 255, 0.06);
backdrop-filter: blur(10px);
/* Breathing glow for highlighted stat cards */
.active-glow {
animation: breatheGlow 3s ease-in-out infinite alternate;
}
}
@@ -205,11 +253,11 @@
border: 1px solid hsl(var(--border)) !important;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3) !important;
border-radius: 0.75rem;
font-family: 'Inter', system-ui, sans-serif;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
}
[data-sonner-toast] [data-title] {
font-family: 'Inter', system-ui, sans-serif;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
font-weight: 600;
}

View File

@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
import { LeaveAccountModal } from '@/components/account/LeaveAccountModal'
import { DeleteAccountModal } from '@/components/account/DeleteAccountModal'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions'
@@ -29,6 +32,11 @@ export function AccountSettingsPage() {
const [editedName, setEditedName] = useState('')
const [isSavingName, setIsSavingName] = useState(false)
// Modals
const [showTransferModal, setShowTransferModal] = useState(false)
const [showLeaveModal, setShowLeaveModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
// Invite form
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState('engineer')
@@ -341,16 +349,31 @@ export function AccountSettingsPage() {
<p className="text-xs text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-3">
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
member.account_role === 'owner' && 'bg-accent text-foreground',
member.account_role === 'engineer' && 'bg-accent text-muted-foreground',
member.account_role === 'viewer' && 'bg-accent text-muted-foreground'
)}
>
{member.account_role}
</span>
{member.account_role === 'owner' ? (
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent text-foreground">
owner
</span>
) : (
<select
value={member.account_role}
onChange={async (e) => {
try {
const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
setMembers(members.map((m) => m.id === member.id ? { ...m, account_role: updated.account_role } : m))
toast.success(`Role updated to ${updated.account_role}`)
} catch {
toast.error('Failed to update role')
}
}}
className={cn(
'rounded-md border border-border bg-card px-2 py-0.5 text-xs',
'text-foreground focus:border-primary focus:outline-none'
)}
>
<option value="engineer">engineer</option>
<option value="viewer">viewer</option>
</select>
)}
{!member.is_active && (
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400">
Inactive
@@ -478,6 +501,21 @@ export function AccountSettingsPage() {
</div>
)}
{/* Profile Settings Link */}
<Link
to="/account/profile"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<UserCog className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Profile Settings</h2>
<p className="text-sm text-muted-foreground">Update your name, email, and personal details</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
{/* Team Categories Link (owners only) */}
{isAccountOwner && (
<Link
@@ -512,6 +550,23 @@ export function AccountSettingsPage() {
</Link>
)}
{/* Chat Retention Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/chat-retention"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Chat Retention</h2>
<p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Feedback Link (all users) */}
<Link
to="/feedback"
@@ -563,7 +618,85 @@ export function AccountSettingsPage() {
</select>
</div>
</div>
{/* Danger Zone */}
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="h-5 w-5 text-rose-500" />
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
</div>
<div className="space-y-3">
{isAccountOwner ? (
<>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Transfer Ownership</p>
<p className="text-xs text-muted-foreground">Make another member the account owner</p>
</div>
<button
onClick={() => setShowTransferModal(true)}
className={cn(
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
'border border-amber-500/30 text-amber-400 hover:bg-amber-500/10'
)}
>
Transfer
</button>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<div>
<p className="text-sm font-medium text-foreground">Delete Account</p>
<p className="text-xs text-muted-foreground">Permanently delete your account and all data</p>
</div>
<button
onClick={() => setShowDeleteModal(true)}
className={cn(
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
)}
>
Delete
</button>
</div>
</>
) : (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">Leave Account</p>
<p className="text-xs text-muted-foreground">Leave this account and create a personal one</p>
</div>
<button
onClick={() => setShowLeaveModal(true)}
className={cn(
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
)}
>
Leave
</button>
</div>
)}
</div>
</div>
</div>
{/* Modals */}
{showTransferModal && (
<TransferOwnershipModal
members={members}
onClose={() => setShowTransferModal(false)}
onTransferred={() => { setShowTransferModal(false); loadData() }}
/>
)}
{showLeaveModal && account && (
<LeaveAccountModal
accountName={account.name}
onClose={() => setShowLeaveModal(false)}
/>
)}
{showDeleteModal && (
<DeleteAccountModal onClose={() => setShowDeleteModal(false)} />
)}
</div>
)
}

View File

@@ -0,0 +1,230 @@
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, AssistantChatMessage as ChatMessageType } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
interface MessageWithMeta extends ChatMessageType {
suggestedFlows?: SuggestedFlow[]
}
export default function AssistantChatPage() {
const [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(null)
const [messages, setMessages] = useState<MessageWithMeta[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
// Load chat list
useEffect(() => {
loadChats()
}, [])
// Auto-scroll
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const loadChats = async () => {
try {
const list = await assistantChatApi.listChats(1, 100)
setChats(list)
} catch {
// silently handle
}
}
const selectChat = useCallback(async (chatId: string) => {
setActiveChatId(chatId)
try {
const chat = await assistantChatApi.getChat(chatId)
setMessages(chat.messages.map(m => ({ ...m })))
} catch {
setMessages([])
}
}, [])
const handleNewChat = async () => {
try {
const chat = await assistantChatApi.createChat()
setChats(prev => [
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
...prev,
])
setActiveChatId(chat.id)
setMessages([])
} catch {
toast.error('Failed to create chat')
}
}
const handleDeleteChat = async (chatId: string) => {
try {
await assistantChatApi.deleteChat(chatId)
setChats(prev => prev.filter(c => c.id !== chatId))
if (activeChatId === chatId) {
setActiveChatId(null)
setMessages([])
}
} catch {
toast.error('Failed to delete chat')
}
}
const handleTogglePin = async (chatId: string, pinned: boolean) => {
try {
await assistantChatApi.updateChat(chatId, { pinned })
setChats(prev =>
prev.map(c => c.id === chatId ? { ...c, pinned } : c)
)
} catch {
toast.error('Failed to update chat')
}
}
const handleSend = async () => {
if (!input.trim() || !activeChatId || loading) return
const userMessage = input.trim()
setInput('')
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true)
try {
const response = await assistantChatApi.sendMessage(activeChatId, userMessage)
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
])
// Update chat list title if it was the first message
setChats(prev =>
prev.map(c =>
c.id === activeChatId
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
: c
)
)
} catch {
setMessages(prev => [
...prev,
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
])
} finally {
setLoading(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="flex h-[calc(100vh-3.5rem)]">
{/* Sidebar */}
<ChatSidebar
chats={chats}
activeChatId={activeChatId}
onSelectChat={selectChat}
onNewChat={handleNewChat}
onDeleteChat={handleDeleteChat}
onTogglePin={handleTogglePin}
/>
{/* Main chat area */}
<div className="flex-1 flex flex-col min-w-0">
{activeChatId ? (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{messages.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Sparkles size={28} className="text-primary" />
</div>
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
AI Assistant
</h2>
<p className="text-sm text-muted-foreground max-w-md">
Ask me anything about IT infrastructure, networking, Active Directory,
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
</p>
</div>
)}
{messages.map((msg, i) => (
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
suggestedFlows={msg.suggestedFlows}
/>
))}
{loading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
<Sparkles size={14} className="text-primary" />
</div>
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-2xl px-4 py-3">
<Loader2 size={16} className="animate-spin text-primary" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<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}
placeholder="Ask about IT, networking, troubleshooting..."
rows={1}
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
disabled={loading}
/>
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
>
<Send size={18} />
</button>
</div>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Sparkles size={32} className="text-primary" />
</div>
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
AI Assistant
</h2>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
or start a new chat to get personalized help with your team's flows.
</p>
<button
onClick={handleNewChat}
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-6 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
>
Start a Conversation
</button>
</div>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -40,38 +40,59 @@ export function LoginPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-black px-4">
{/* Subtle radial overlay */}
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="flex min-h-screen items-center justify-center bg-background px-4">
{/* Atmosphere orbs */}
<div
className="pointer-events-none fixed z-0"
style={{
top: '-120px',
right: '-80px',
width: '600px',
height: '600px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, rgba(6, 182, 212, 0.04) 40%, transparent 70%)',
filter: 'blur(60px)',
}}
/>
<div
className="pointer-events-none fixed z-0"
style={{
bottom: '-100px',
left: '-60px',
width: '500px',
height: '500px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, rgba(139, 92, 246, 0.02) 40%, transparent 70%)',
filter: 'blur(60px)',
}}
/>
<div className="relative w-full max-w-md space-y-8">
<div className="relative z-10 w-full max-w-md space-y-8">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
</div>
<BrandLogo size="lg" />
</div>
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
ResolutionFlow
<span>Resolution</span><span className="text-gradient-brand">Flow</span>
</h1>
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
Decision Tree Platform
</p>
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
<p className="mt-1 text-sm text-muted-foreground/70 sm:mt-2">
Sign in to your account
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
<div className="glass-card-static p-6 space-y-4">
{(error || localError) && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
<div className="rounded-[10px] border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-400">
{localError || error}
</div>
)}
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium text-foreground">
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
Email address
</label>
<input
@@ -83,9 +104,9 @@ export function LoginPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(
'block w-full rounded-xl border border-border bg-card px-3 py-2',
'block w-full rounded-[10px] border border-border bg-card px-3 py-2.5',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20',
'transition-colors'
)}
placeholder="you@example.com"
@@ -93,7 +114,7 @@ export function LoginPage() {
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium text-foreground">
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-foreground">
Password
</label>
<PasswordInput
@@ -104,9 +125,9 @@ export function LoginPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-border bg-card px-3 py-2',
'block w-full rounded-[10px] border border-border bg-card px-3 py-2.5',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20',
'transition-colors'
)}
placeholder="••••••••••"
@@ -123,9 +144,9 @@ export function LoginPage() {
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90',
'focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
'w-full rounded-[10px] px-4 py-2.5 text-sm font-semibold',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
'focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-background',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}

View File

@@ -23,6 +23,8 @@ import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceCon
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
interface StepState {
notes: string
@@ -84,6 +86,7 @@ export function ProceduralNavigationPage() {
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
const [isSavingStep, setIsSavingStep] = useState(false)
const [copilotOpen, setCopilotOpen] = useState(false)
// Get procedural steps from tree
const getSteps = (): ProceduralStep[] => {
@@ -704,6 +707,20 @@ export function ProceduralNavigationPage() {
isSaving={isSavingStep}
/>
)}
{/* AI Copilot */}
{treeId && (
<>
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
<CopilotPanel
isOpen={copilotOpen}
onClose={() => setCopilotOpen(false)}
treeId={treeId}
sessionId={session?.id}
currentNodeId={runtimeSteps[currentStepIndex]?.id}
/>
</>
)}
</div>
)
}

View File

@@ -12,8 +12,7 @@ import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePaginationParams } from '@/hooks/usePaginationParams'
import { useCachedQuota } from '@/hooks/useCachedQuota'
import { QuickStats } from '@/components/dashboard/QuickStats'
import { SessionsPanel } from '@/components/dashboard/SessionsPanel'
// QuickStats and SessionsPanel replaced by new dashboard panels
import { TreeGridView } from '@/components/library/TreeGridView'
import { TreeListView } from '@/components/library/TreeListView'
import { TreeTableView } from '@/components/library/TreeTableView'
@@ -22,6 +21,10 @@ import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { WeeklyCalendar } from '@/components/dashboard/WeeklyCalendar'
import { QuickActions } from '@/components/dashboard/QuickActions'
import { OpenSessions } from '@/components/dashboard/OpenSessions'
import { RecentActivity } from '@/components/dashboard/RecentActivity'
function timeAgo(dateStr: string): string {
const now = Date.now()
@@ -215,15 +218,21 @@ export function QuickStartPage() {
const now = new Date()
return d.toDateString() === now.toDateString()
}).length
const completedSessions = allSessions.filter(s => s.completed_at).length
// completedSessions removed — no longer displayed in new layout
const recentSessionItems = allSessions.slice(0, 5).map(s => ({
id: s.id,
treeName: s.tree_snapshot?.name || 'Unknown',
status: (s.completed_at ? 'completed' : 'in_progress') as 'completed' | 'in_progress',
ticketNumber: s.ticket_number || undefined,
timeAgo: timeAgo(s.started_at),
}))
// Open sessions for the new panel (3 oldest)
const openSessionItems = activeSessions
.sort((a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime())
.slice(0, 3)
.map(s => ({
id: s.id,
treeName: s.tree_snapshot?.name || 'Unknown',
treeId: s.tree_id,
treeType: (s.tree_snapshot as unknown as Record<string, unknown>)?.tree_type as string | undefined,
timeAgo: timeAgo(s.started_at),
}))
// recentSessionItems removed — replaced by RecentActivity component
// Favorites display
const MAX_VISIBLE_FAVORITES = 8
@@ -270,297 +279,329 @@ export function QuickStartPage() {
return (
<div className="p-6 space-y-6">
{/* Page Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="font-heading text-[1.375rem] font-bold tracking-tight text-foreground">
Dashboard
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Welcome back. Here&apos;s what&apos;s happening with your flows.
</p>
{/* Greeting */}
<div className="fade-in" style={{ animationDelay: '100ms' }}>
<h1 className="font-heading text-4xl font-extrabold tracking-tight text-foreground">
Good {new Date().getHours() < 12 ? 'morning' : new Date().getHours() < 18 ? 'afternoon' : 'evening'}, {user?.name?.split(' ')[0] || 'there'}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
</p>
</div>
{/* Row 1: Calendar + Quick Actions */}
<div className="flex gap-4" style={{ alignItems: 'stretch' }}>
<div className="flex-1 min-w-0">
<WeeklyCalendar />
</div>
<div className="w-72 shrink-0">
<QuickActions />
</div>
</div>
{/* Quick Stats */}
<QuickStats
stats={[
{ label: 'My Flows', value: myFlows.length, gradient: true },
{ label: 'Sessions Today', value: todaySessions, color: '#f59e0b' },
{ label: 'Open Sessions', value: openSessions, meta: `${completedSessions} completed` },
{ label: 'Favorites', value: pinnedItems.length },
]}
/>
{/* Search */}
<div ref={searchRef} className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query.length >= 2 && setShowResults(true)}
placeholder="Search flows, sessions, tags…"
className="w-full rounded-lg border border-border bg-card py-2.5 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
{showResults && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
{isSearching ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
{/* Row 2: Open Sessions + Stats 2x2 */}
<div className="flex gap-4" style={{ alignItems: 'stretch' }}>
<div className="flex-1 min-w-0">
<OpenSessions sessions={openSessionItems} />
</div>
<div className="w-72 shrink-0">
<div className="grid grid-cols-2 gap-3 h-full">
{[
{ label: 'Active Flows', value: myFlows.length, gradient: true, glow: true },
{ label: 'This Week', value: todaySessions },
{ label: 'Open Sessions', value: openSessions },
{ label: 'Favorites', value: pinnedItems.length },
].map((stat, i) => (
<div
key={stat.label}
className={cn('glass-card p-4 flex flex-col justify-between fade-in', stat.glow && 'active-glow')}
style={{ animationDelay: `${500 + i * 70}ms` }}
>
<p className="font-label text-[0.625rem] font-medium uppercase tracking-[0.1em] text-muted-foreground">
{stat.label}
</p>
<p className={cn('font-heading text-2xl font-extrabold tracking-tight', stat.gradient && 'text-gradient-brand')}>
{stat.value}
</p>
</div>
) : searchResults.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">No results found</div>
) : (
<ul className="max-h-72 overflow-y-auto py-1">
{searchResults.map((tree) => (
<li key={tree.id}>
<button
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
className="w-full px-4 py-3 text-left transition-colors hover:bg-accent"
>
<div className="text-sm font-medium text-foreground">{tree.name}</div>
{tree.description && (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{tree.description}</div>
)}
</button>
</li>
))}
</ul>
)}
))}
</div>
)}
</div>
</div>
{/* Recent Sessions */}
<SessionsPanel sessions={recentSessionItems} delay={150} />
{/* Row 3: Recent Activity */}
<RecentActivity />
{/* Favorites Section */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold text-foreground">
Favorites
{pinnedItems.length > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">({pinnedItems.length})</span>
)}
</h2>
{hasMoreFavorites && (
<button
onClick={() => setShowAllFavorites(!showAllFavorites)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{showAllFavorites ? 'Show less' : 'View all favorites'}
</button>
{/* ── Existing content below ── */}
<div style={{ borderTop: '1px solid var(--glass-border)' }} className="pt-6 space-y-6">
{/* Search */}
<div ref={searchRef} className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query.length >= 2 && setShowResults(true)}
placeholder="Search flows, sessions, tags…"
className="w-full rounded-lg border border-border bg-card py-2.5 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
{showResults && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
{isSearching ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : searchResults.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">No results found</div>
) : (
<ul className="max-h-72 overflow-y-auto py-1">
{searchResults.map((tree) => (
<li key={tree.id}>
<button
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
className="w-full px-4 py-3 text-left transition-colors hover:bg-accent"
>
<div className="text-sm font-medium text-foreground">{tree.name}</div>
{tree.description && (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{tree.description}</div>
)}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
{pinnedIsLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-20 rounded-xl bg-card border border-border animate-pulse" />
))}
</div>
) : pinnedItems.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
Star a flow to pin it here for quick access.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{visibleFavorites.map((flow) => (
<button
key={flow.tree_id}
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
className="group relative flex items-center gap-3 rounded-xl bg-card border border-border p-4 text-left transition-colors hover:border-border/80 hover:bg-accent/50"
>
<span className="text-lg shrink-0">
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
</span>
<span className="truncate text-sm font-medium text-foreground">{flow.tree_name}</span>
<button
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
togglePin(flow.tree_id)
}}
aria-label="Remove from favorites"
className="absolute top-2 right-2 rounded-md p-1 text-amber-400 opacity-0 group-hover:opacity-100 hover:text-amber-300 transition-all"
>
<Star size={14} fill="currentColor" />
</button>
</button>
))}
</div>
)}
</div>
{/* My Flows Section — tabbed */}
<div>
<div className="mb-3 flex items-center gap-1 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => { setActiveTab(tab.id); setPage(1) }}
className={cn(
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
activeTab === tab.id
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
{/* Favorites Section */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold text-foreground">
Favorites
{pinnedItems.length > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">({pinnedItems.length})</span>
)}
>
{tab.label}
</button>
))}
<div className="ml-auto flex items-center gap-2 pb-1.5">
{activeTab === 'mine' && canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
/>
</h2>
{hasMoreFavorites && (
<button
onClick={() => setShowAllFavorites(!showAllFavorites)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{showAllFavorites ? 'Show less' : 'View all favorites'}
</button>
)}
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
</div>
{pinnedIsLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-20 rounded-xl bg-card border border-border animate-pulse" />
))}
</div>
) : pinnedItems.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
Star a flow to pin it here for quick access.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{visibleFavorites.map((flow) => (
<button
key={flow.tree_id}
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
className="group relative flex items-center gap-3 rounded-xl bg-card border border-border p-4 text-left transition-colors hover:border-border/80 hover:bg-accent/50"
>
<span className="text-lg shrink-0">
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
</span>
<span className="truncate text-sm font-medium text-foreground">{flow.tree_name}</span>
<button
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
togglePin(flow.tree_id)
}}
aria-label="Remove from favorites"
className="absolute top-2 right-2 rounded-md p-1 text-amber-400 opacity-0 group-hover:opacity-100 hover:text-amber-300 transition-all"
>
<Star size={14} fill="currentColor" />
</button>
</button>
))}
</div>
)}
</div>
{isLoadingFlows ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 rounded-xl bg-card border border-border animate-pulse" />
{/* My Flows Section — tabbed */}
<div>
<div className="mb-3 flex items-center gap-1 border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => { setActiveTab(tab.id); setPage(1) }}
className={cn(
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
activeTab === tab.id
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
</button>
))}
<div className="ml-auto flex items-center gap-2 pb-1.5">
{activeTab === 'mine' && canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
/>
)}
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
</div>
</div>
) : myFlows.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground mb-4">
{activeTab === 'mine'
? "You haven't created any flows yet."
: activeTab === 'team'
? 'No team flows found.'
: activeTab === 'public'
? 'No public flows found.'
: 'No flows found.'}
</p>
{activeTab === 'mine' && canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create your first flow"
/>
)}
</div>
) : (
<>
{allFlowsCeiling && (
<p className="mb-3 text-sm text-muted-foreground">
Showing first 500 flows. Use search or filters to find specific flows.
{isLoadingFlows ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 rounded-xl bg-card border border-border animate-pulse" />
))}
</div>
) : myFlows.length === 0 ? (
<div className="py-12 text-center">
<p className="text-muted-foreground mb-4">
{activeTab === 'mine'
? "You haven't created any flows yet."
: activeTab === 'team'
? 'No team flows found.'
: activeTab === 'public'
? 'No public flows found.'
: 'No flows found.'}
</p>
)}
{activeTab === 'mine' && canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create your first flow"
/>
)}
</div>
) : (
<>
{allFlowsCeiling && (
<p className="mb-3 text-sm text-muted-foreground">
Showing first 500 flows. Use search or filters to find specific flows.
</p>
)}
{dashboardMyFlowsView === 'grid' && (
<TreeGridView
trees={myFlows}
onStartSession={handleStartSession}
onTagClick={handleTagClick}
onFolderCreated={handleFolderCreated}
onDeleteTree={handleDeleteTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
/>
)}
{dashboardMyFlowsView === 'list' && (
<TreeListView
trees={myFlows}
onStartSession={handleStartSession}
onTagClick={handleTagClick}
onFolderCreated={handleFolderCreated}
onDeleteTree={handleDeleteTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
/>
)}
{dashboardMyFlowsView === 'table' && (
<TreeTableView
trees={myFlows}
onStartSession={handleStartSession}
onTagClick={handleTagClick}
onFolderCreated={handleFolderCreated}
onDeleteTree={handleDeleteTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
/>
)}
{dashboardMyFlowsView === 'grid' && (
<TreeGridView
trees={myFlows}
onStartSession={handleStartSession}
onTagClick={handleTagClick}
onFolderCreated={handleFolderCreated}
onDeleteTree={handleDeleteTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
/>
)}
{dashboardMyFlowsView === 'list' && (
<TreeListView
trees={myFlows}
onStartSession={handleStartSession}
onTagClick={handleTagClick}
onFolderCreated={handleFolderCreated}
onDeleteTree={handleDeleteTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
/>
)}
{dashboardMyFlowsView === 'table' && (
<TreeTableView
trees={myFlows}
onStartSession={handleStartSession}
onTagClick={handleTagClick}
onFolderCreated={handleFolderCreated}
onDeleteTree={handleDeleteTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
/>
)}
{/* Pagination controls */}
{pageSize !== 'all' && (
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className={cn(
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
)}
>
<ChevronLeft size={14} />
Prev
</button>
<span className="text-sm text-muted-foreground">Page {page}</span>
<button
onClick={() => setPage(page + 1)}
disabled={!hasNextPage}
className={cn(
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
!hasNextPage ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
)}
>
Next
<ChevronRight size={14} />
</button>
{/* Pagination controls */}
{pageSize !== 'all' && (
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className={cn(
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
)}
>
<ChevronLeft size={14} />
Prev
</button>
<span className="text-sm text-muted-foreground">Page {page}</span>
<button
onClick={() => setPage(page + 1)}
disabled={!hasNextPage}
className={cn(
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
!hasNextPage ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
)}
>
Next
<ChevronRight size={14} />
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
<select
value={String(pageSize)}
onChange={(e) => {
const val = e.target.value
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
}}
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
>
{pageSizeOptions.map((opt) => (
<option key={String(opt)} value={String(opt)}>
{opt === 'all' ? 'All' : opt}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
<select
value={String(pageSize)}
onChange={(e) => {
const val = e.target.value
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
}}
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
>
{pageSizeOptions.map((opt) => (
<option key={String(opt)} value={String(opt)}>
{opt === 'all' ? 'All' : opt}
</option>
))}
</select>
)}
{pageSize === 'all' && (
<div className="mt-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
<select
value="all"
onChange={(e) => {
const val = e.target.value
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
}}
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
>
{pageSizeOptions.map((opt) => (
<option key={String(opt)} value={String(opt)}>
{opt === 'all' ? 'All' : opt}
</option>
))}
</select>
</div>
</div>
</div>
)}
{pageSize === 'all' && (
<div className="mt-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Show:</span>
<select
value="all"
onChange={(e) => {
const val = e.target.value
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
}}
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
>
{pageSizeOptions.map((opt) => (
<option key={String(opt)} value={String(opt)}>
{opt === 'all' ? 'All' : opt}
</option>
))}
</select>
</div>
</div>
)}
</>
)}
)}
</>
)}
</div>
</div>
{/* Fork Modal */}

View File

@@ -19,6 +19,8 @@ import { CSATModal } from '@/components/session/CSATModal'
import { hasBeenRated } from '@/components/session/csatUtils'
import { StepFeedback } from '@/components/session/StepFeedback'
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
interface LocationState {
sessionId?: string
@@ -60,6 +62,7 @@ export function TreeNavigationPage() {
const [copiedShareLink, setCopiedShareLink] = useState(false)
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
const sharePopoverRef = useRef<HTMLDivElement>(null)
const [copilotOpen, setCopilotOpen] = useState(false)
const handleCopyCommand = (text: string) => {
navigator.clipboard.writeText(text)
@@ -1270,6 +1273,20 @@ export function TreeNavigationPage() {
onOpenChange={setScratchpadOpen}
/>
)}
{/* AI Copilot */}
{treeId && (
<>
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
<CopilotPanel
isOpen={copilotOpen}
onClose={() => setCopilotOpen(false)}
treeId={treeId}
sessionId={session?.id}
currentNodeId={currentNodeId}
/>
</>
)}
</div>
)
}

View File

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

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { Save, Loader2, Clock } from 'lucide-react'
import { assistantChatApi } from '@/api/assistantChat'
export default function ChatRetentionSettingsPage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [retentionDays, setRetentionDays] = useState('')
const [maxCount, setMaxCount] = useState('')
const [success, setSuccess] = useState(false)
useEffect(() => {
loadSettings()
}, [])
const loadSettings = async () => {
try {
const data = await assistantChatApi.getRetentionSettings()
setRetentionDays(data.chat_retention_days?.toString() ?? '90')
setMaxCount(data.chat_retention_max_count?.toString() ?? '100')
} catch {
// silently handle
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
setSuccess(false)
try {
await assistantChatApi.updateRetentionSettings({
chat_retention_days: parseInt(retentionDays) || null,
chat_retention_max_count: parseInt(maxCount) || null,
})
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} catch {
// silently handle
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin text-primary" size={24} />
</div>
)
}
return (
<div className="max-w-2xl mx-auto py-8 px-6">
<div className="flex items-center gap-3 mb-6">
<Clock size={20} className="text-primary" />
<h1 className="text-xl font-heading font-bold text-foreground">Chat Retention</h1>
</div>
<div className="glass-card-static rounded-2xl p-6 space-y-6">
<p className="text-sm text-muted-foreground">
Configure how long AI assistant conversations are retained. Pinned chats are never automatically deleted.
</p>
<div className="space-y-4">
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-1.5">
Retention Period (days)
</label>
<input
type="number"
value={retentionDays}
onChange={e => setRetentionDays(e.target.value)}
min={1}
max={365}
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
/>
<p className="text-xs text-muted-foreground mt-1">
Chats older than this will be automatically deleted (1-365 days)
</p>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-1.5">
Max Conversations
</label>
<input
type="number"
value={maxCount}
onChange={e => setMaxCount(e.target.value)}
min={10}
max={10000}
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
/>
<p className="text-xs text-muted-foreground mt-1">
When this limit is exceeded, oldest unpinned chats are deleted
</p>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40 flex items-center gap-2"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save Settings
</button>
{success && (
<span className="text-sm text-emerald-400">Settings saved</span>
)}
</div>
</div>
</div>
)
}

View File

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

View File

@@ -18,6 +18,7 @@ export function SettingsPage() {
const maintenanceMode = Boolean(settings.maintenance_mode)
const maintenanceMessage = String(settings.maintenance_message || '')
const emailVerificationEnabled = settings.email_verification_enabled !== false
const handleSave = async () => {
setSaving(true)
@@ -46,6 +47,27 @@ export function SettingsPage() {
<PageHeader title="Platform Settings" description="Global platform configuration" />
<div className="max-w-xl space-y-6 bg-card border border-border rounded-xl p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-foreground">Email Verification</h3>
<p className="text-sm text-muted-foreground">
When enabled, unverified users see a banner prompting them to verify their email.
</p>
</div>
<button
onClick={() => setSettings({ ...settings, email_verification_enabled: !emailVerificationEnabled })}
className={cn(
'h-6 w-10 rounded-full transition-colors',
emailVerificationEnabled ? 'bg-gradient-brand' : 'bg-accent'
)}
>
<div className={cn(
'h-4 w-4 rounded-full bg-white transition-transform',
emailVerificationEnabled ? 'translate-x-5' : 'translate-x-1'
)} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-foreground">Maintenance Mode</h3>

View File

@@ -12,6 +12,7 @@ import {
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
// Standalone auth pages
const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage'))
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'))
@@ -34,6 +35,9 @@ const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
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'))
@@ -49,8 +53,10 @@ const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategor
// Account pages
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
const ProfileSettingsPage = lazy(() => import('@/pages/account/ProfileSettingsPage'))
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
export const router = createBrowserRouter([
{
@@ -81,6 +87,15 @@ export const router = createBrowserRouter([
),
errorElement: <RouteError />,
},
{
path: '/verify-email',
element: (
<Suspense fallback={<PageLoader />}>
<VerifyEmailPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/share/:shareToken',
element: (
@@ -262,6 +277,30 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'assistant',
element: (
<Suspense fallback={<PageLoader />}>
<AssistantChatPage />
</Suspense>
),
},
{
path: 'guides',
element: (
<Suspense fallback={<PageLoader />}>
<GuidesHubPage />
</Suspense>
),
},
{
path: 'guides/:slug',
element: (
<Suspense fallback={<PageLoader />}>
<GuideDetailPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin',
@@ -364,6 +403,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'profile',
element: (
<Suspense fallback={<PageLoader />}>
<ProfileSettingsPage />
</Suspense>
),
},
{
path: 'categories',
element: (
@@ -374,6 +421,16 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'chat-retention',
element: (
<Suspense fallback={<PageLoader />}>
<ProtectedRoute requiredRole="owner">
<ChatRetentionSettingsPage />
</ProtectedRoute>
</Suspense>
),
},
{
path: 'target-lists',
element: (

View File

@@ -0,0 +1,37 @@
import type { SuggestedFlow } from './copilot'
export interface AssistantChat {
id: string
title: string
messages: AssistantChatMessage[]
message_count: number
pinned: boolean
created_at: string
updated_at: string
}
export interface AssistantChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface ChatListItem {
id: string
title: string
message_count: number
pinned: boolean
created_at: string
updated_at: string
}
export interface ChatMessageResponse {
content: string
suggested_flows: SuggestedFlow[]
}
export interface RetentionSettings {
chat_retention_days: number | null
chat_retention_max_count: number | null
}
export type { SuggestedFlow }

View File

@@ -0,0 +1,41 @@
export interface SuggestedFlow {
tree_id: string
tree_name: string
tree_type: string
relevance_snippet: string
}
export interface CopilotStartRequest {
tree_id: string
session_id?: string
current_node_id?: string
}
export interface CopilotStartResponse {
conversation_id: string
greeting: string
}
export interface CopilotMessageRequest {
message: string
current_node_id?: string
}
export interface CopilotMessageResponse {
content: string
suggested_flows: SuggestedFlow[]
}
export interface CopilotConversation {
id: string
tree_id: string
messages: CopilotMessage[]
current_node_id?: string
message_count: number
created_at: string
}
export interface CopilotMessage {
role: 'user' | 'assistant'
content: string
}

View File

@@ -10,6 +10,8 @@ export * from './step'
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
export * from './admin'
export * from './analytics'
export * from './copilot'
export type { AssistantChat, AssistantChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
// API response wrapper types
export interface PaginatedResponse<T> {

View File

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

View File

@@ -11,20 +11,20 @@ export default {
// ResolutionFlow Brand Colors
brand: {
gradient: {
from: '#818cf8',
to: '#a78bfa',
from: '#06b6d4',
to: '#22d3ee',
},
dark: {
DEFAULT: '#09090b',
card: '#18181b',
surface: '#12121c',
DEFAULT: '#101114',
card: '#14161a',
surface: '#14161a',
},
text: {
primary: '#ffffff',
secondary: '#a1a1aa',
muted: '#52525b',
primary: '#f8fafc',
secondary: '#8891a0',
muted: '#5a6170',
},
border: '#27272a',
border: 'rgba(255, 255, 255, 0.06)',
},
// shadcn/ui color system
border: "hsl(var(--border))",
@@ -67,13 +67,13 @@ export default {
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
heading: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'],
label: ['Outfit', 'system-ui', 'sans-serif'],
sans: ['IBM Plex Sans', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
heading: ['Bricolage Grotesque', 'system-ui', 'sans-serif'],
label: ['JetBrains Mono', 'monospace'],
},
backgroundImage: {
'gradient-brand': 'linear-gradient(90deg, #818cf8 0%, #a78bfa 100%)',
'gradient-brand-hover': 'linear-gradient(90deg, #6366f1 0%, #9333ea 100%)',
'gradient-brand': 'linear-gradient(135deg, #06b6d4 0%, #22d3ee 100%)',
'gradient-brand-hover': 'linear-gradient(135deg, #0891b2 0%, #06b6d4 100%)',
},
},
},

View File

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