feat: Slate & Ice Modern aesthetic redesign #94
53
CLAUDE.md
53
CLAUDE.md
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
30
backend/alembic/versions/040_add_user_profile_fields.py
Normal file
30
backend/alembic/versions/040_add_user_profile_fields.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Add user profile fields (phone, job_title, timezone, avatar_url, email_verified_at)
|
||||
|
||||
Revision ID: 040
|
||||
Revises: fb1481317ff6
|
||||
Create Date: 2026-03-03
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "040"
|
||||
down_revision = "e2d81e82ea5e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("phone", sa.String(50), nullable=True))
|
||||
op.add_column("users", sa.Column("job_title", sa.String(255), nullable=True))
|
||||
op.add_column("users", sa.Column("timezone", sa.String(100), nullable=False, server_default="UTC"))
|
||||
op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True))
|
||||
op.add_column("users", sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "email_verified_at")
|
||||
op.drop_column("users", "avatar_url")
|
||||
op.drop_column("users", "timezone")
|
||||
op.drop_column("users", "job_title")
|
||||
op.drop_column("users", "phone")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add email_verification_tokens table
|
||||
|
||||
Revision ID: 041
|
||||
Revises: 040
|
||||
Create Date: 2026-03-03
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "041"
|
||||
down_revision = "040"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"email_verification_tokens",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("token_hash", sa.String(64), unique=True, nullable=False, index=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("email_verification_tokens")
|
||||
@@ -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")
|
||||
39
backend/alembic/versions/043_add_copilot_conversations.py
Normal file
39
backend/alembic/versions/043_add_copilot_conversations.py
Normal 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")
|
||||
37
backend/alembic/versions/044_add_assistant_chats.py
Normal file
37
backend/alembic/versions/044_add_assistant_chats.py
Normal 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")
|
||||
31
backend/alembic/versions/045_add_chat_retention_settings.py
Normal file
31
backend/alembic/versions/045_add_chat_retention_settings.py
Normal 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")
|
||||
@@ -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"}
|
||||
|
||||
320
backend/app/api/endpoints/assistant_chat.py
Normal file
320
backend/app/api/endpoints/assistant_chat.py
Normal 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,
|
||||
)
|
||||
@@ -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"}
|
||||
|
||||
192
backend/app/api/endpoints/copilot.py
Normal file
192
backend/app/api/endpoints/copilot.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -163,6 +163,39 @@ class EmailService:
|
||||
logger.exception("Failed to send account invite email to %s", to_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_email_verification_email(
|
||||
to_email: str,
|
||||
verification_url: str,
|
||||
) -> bool:
|
||||
if not settings.email_enabled:
|
||||
logger.warning("Email not sent — RESEND_API_KEY not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
subject = "Verify Your Email — ResolutionFlow"
|
||||
|
||||
html = _render_email_verification_html(verification_url=verification_url)
|
||||
|
||||
resend.Emails.send(
|
||||
{
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [to_email],
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
}
|
||||
)
|
||||
logger.info("Verification email sent to %s", to_email)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send verification email to %s", to_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_feedback_email(
|
||||
to_email: str,
|
||||
@@ -485,6 +518,38 @@ def _render_feedback_html(
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _render_email_verification_html(verification_url: str) -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
|
||||
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;">
|
||||
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
|
||||
Please verify your email address by clicking the button below. This link expires in 24 hours.
|
||||
</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;text-align:center;">
|
||||
<a href="{verification_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
|
||||
Verify Email
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _render_feedback_confirmation_html(
|
||||
feedback_type: str,
|
||||
message_preview: str,
|
||||
|
||||
@@ -70,6 +70,19 @@ def create_password_reset_token(user_id: str) -> str:
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_email_verification_token(user_id: str) -> str:
|
||||
"""Create a JWT email verification token (24-hour expiry, unique JTI)."""
|
||||
jti = str(uuid.uuid4())
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
to_encode = {
|
||||
"sub": user_id,
|
||||
"type": "email_verification",
|
||||
"jti": jti,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def generate_temp_password(length: int = 16) -> str:
|
||||
"""Generate a temporary password with guaranteed complexity.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
59
backend/app/models/assistant_chat.py
Normal file
59
backend/app/models/assistant_chat.py
Normal 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),
|
||||
)
|
||||
69
backend/app/models/copilot_conversation.py
Normal file
69
backend/app/models/copilot_conversation.py
Normal 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
|
||||
)
|
||||
42
backend/app/models/email_verification_token.py
Normal file
42
backend/app/models/email_verification_token.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class EmailVerificationToken(Base):
|
||||
__tablename__ = "email_verification_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_used(self) -> bool:
|
||||
return self.used_at is not None
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return datetime.now(timezone.utc) > self.expires_at
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return not self.is_used and not self.is_expired
|
||||
72
backend/app/models/tree_embedding.py
Normal file
72
backend/app/models/tree_embedding.py
Normal 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),
|
||||
)
|
||||
@@ -68,6 +68,15 @@ class User(Base):
|
||||
)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Profile fields
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC")
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
email_verified_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# AI billing cycle anchor (for quota reset calculation)
|
||||
ai_billing_cycle_anchor_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
|
||||
@@ -20,6 +20,11 @@ class AccountUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TransferOwnershipRequest(BaseModel):
|
||||
current_password: str
|
||||
target_user_id: UUID
|
||||
|
||||
|
||||
class AccountInviteCreate(BaseModel):
|
||||
email: str = Field(..., max_length=255)
|
||||
role: str = Field("engineer", pattern="^(engineer|viewer)$")
|
||||
|
||||
59
backend/app/schemas/assistant_chat.py
Normal file
59
backend/app/schemas/assistant_chat.py
Normal 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)
|
||||
44
backend/app/schemas/copilot.py
Normal file
44
backend/app/schemas/copilot.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
122
backend/app/services/assistant_chat_service.py
Normal file
122
backend/app/services/assistant_chat_service.py
Normal 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
|
||||
202
backend/app/services/copilot_service.py
Normal file
202
backend/app/services/copilot_service.py
Normal 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
|
||||
78
backend/app/services/embedding_service.py
Normal file
78
backend/app/services/embedding_service.py
Normal 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
|
||||
209
backend/app/services/rag_service.py
Normal file
209
backend/app/services/rag_service.py
Normal 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]
|
||||
84
backend/app/services/retention_cleanup.py
Normal file
84
backend/app/services/retention_cleanup.py
Normal 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
|
||||
165
backend/app/services/tree_chunker.py
Normal file
165
backend/app/services/tree_chunker.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
109
backend/tests/test_account_lifecycle.py
Normal file
109
backend/tests/test_account_lifecycle.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Tests for leave account and delete account endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLeaveAccount:
|
||||
"""Test POST /accounts/me/leave."""
|
||||
|
||||
async def test_leave_as_non_owner(self, client: AsyncClient, test_db):
|
||||
"""Non-owner can leave and gets a personal account."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
# Register owner
|
||||
owner = await client.post("/api/v1/auth/register", json={
|
||||
"email": "owner@example.com", "password": "TestPassword123!", "name": "Owner",
|
||||
})
|
||||
assert owner.status_code == 201
|
||||
owner_data = owner.json()
|
||||
|
||||
# Login as owner
|
||||
login = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "owner@example.com", "password": "TestPassword123!",
|
||||
})
|
||||
owner_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
# Register member
|
||||
member = await client.post("/api/v1/auth/register", json={
|
||||
"email": "member@example.com", "password": "TestPassword123!", "name": "Member",
|
||||
})
|
||||
member_id = member.json()["id"]
|
||||
|
||||
# Move member to owner's account
|
||||
result = await test_db.execute(select(User).where(User.id == member_id))
|
||||
member_user = result.scalar_one()
|
||||
member_user.account_id = owner_data["account_id"]
|
||||
member_user.account_role = "engineer"
|
||||
await test_db.commit()
|
||||
|
||||
# Login as member
|
||||
login = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "member@example.com", "password": "TestPassword123!",
|
||||
})
|
||||
member_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
# Leave
|
||||
response = await client.post("/api/v1/accounts/me/leave", headers=member_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_leave_as_owner_fails(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Owner cannot leave their own account."""
|
||||
response = await client.post("/api/v1/accounts/me/leave", headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteAccount:
|
||||
"""Test DELETE /accounts/me."""
|
||||
|
||||
async def test_delete_success(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Owner with no other members can delete account."""
|
||||
response = await client.request(
|
||||
"DELETE",
|
||||
"/api/v1/accounts/me",
|
||||
json={"current_password": "TestPassword123!"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_delete_wrong_password(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Wrong password returns 401."""
|
||||
response = await client.request(
|
||||
"DELETE",
|
||||
"/api/v1/accounts/me",
|
||||
json={"current_password": "WrongPassword123!"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_delete_with_members_fails(self, client: AsyncClient, auth_headers: dict, test_db):
|
||||
"""Cannot delete account that has other members."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
# Get owner's account_id
|
||||
me = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
account_id = me.json()["account_id"]
|
||||
|
||||
# Register and add member
|
||||
member = await client.post("/api/v1/auth/register", json={
|
||||
"email": "member2@example.com", "password": "TestPassword123!", "name": "Member",
|
||||
})
|
||||
member_id = member.json()["id"]
|
||||
|
||||
result = await test_db.execute(select(User).where(User.id == member_id))
|
||||
member_user = result.scalar_one()
|
||||
member_user.account_id = account_id
|
||||
member_user.account_role = "engineer"
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.request(
|
||||
"DELETE",
|
||||
"/api/v1/accounts/me",
|
||||
json={"current_password": "TestPassword123!"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
63
backend/tests/test_account_transfer.py
Normal file
63
backend/tests/test_account_transfer.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Tests for account ownership transfer."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestOwnershipTransfer:
|
||||
"""Test POST /accounts/me/transfer-ownership."""
|
||||
|
||||
async def _create_member(self, client: AsyncClient, owner_headers: dict, test_db):
|
||||
"""Register a second user and add them to the owner's account."""
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
# Register second user (gets own account)
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "member@example.com",
|
||||
"password": "TestPassword123!",
|
||||
"name": "Member User",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
member_id = resp.json()["id"]
|
||||
|
||||
# Get owner's account_id
|
||||
me = await client.get("/api/v1/auth/me", headers=owner_headers)
|
||||
owner_account_id = me.json()["account_id"]
|
||||
|
||||
# Move member to owner's account
|
||||
result = await test_db.execute(select(User).where(User.id == member_id))
|
||||
member = result.scalar_one()
|
||||
member.account_id = owner_account_id
|
||||
member.account_role = "engineer"
|
||||
await test_db.commit()
|
||||
|
||||
return member_id
|
||||
|
||||
async def test_transfer_success(self, client: AsyncClient, auth_headers: dict, test_db):
|
||||
member_id = await self._create_member(client, auth_headers, test_db)
|
||||
response = await client.post(
|
||||
"/api/v1/accounts/me/transfer-ownership",
|
||||
json={"current_password": "TestPassword123!", "target_user_id": member_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["owner_id"] == member_id
|
||||
|
||||
async def test_transfer_self(self, client: AsyncClient, auth_headers: dict, test_user):
|
||||
response = await client.post(
|
||||
"/api/v1/accounts/me/transfer-ownership",
|
||||
json={"current_password": "TestPassword123!", "target_user_id": test_user["user_data"]["id"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_transfer_wrong_password(self, client: AsyncClient, auth_headers: dict, test_db):
|
||||
member_id = await self._create_member(client, auth_headers, test_db)
|
||||
response = await client.post(
|
||||
"/api/v1/accounts/me/transfer-ownership",
|
||||
json={"current_password": "WrongPassword123!", "target_user_id": member_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
90
backend/tests/test_auth_profile.py
Normal file
90
backend/tests/test_auth_profile.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Tests for PATCH /auth/me profile update endpoint."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestProfileUpdate:
|
||||
"""Test profile update via PATCH /auth/me."""
|
||||
|
||||
async def test_update_name(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Name update works without password."""
|
||||
response = await client.patch(
|
||||
"/api/v1/auth/me",
|
||||
json={"name": "New Name"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "New Name"
|
||||
|
||||
async def test_update_email_with_password(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Email change with correct password succeeds."""
|
||||
response = await client.patch(
|
||||
"/api/v1/auth/me",
|
||||
json={"email": "newemail@example.com", "current_password": "TestPassword123!"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["email"] == "newemail@example.com"
|
||||
|
||||
async def test_update_email_without_password(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Email change without password returns 400."""
|
||||
response = await client.patch(
|
||||
"/api/v1/auth/me",
|
||||
json={"email": "newemail@example.com"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "password" in response.json()["detail"].lower()
|
||||
|
||||
async def test_update_email_wrong_password(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Email change with wrong password returns 401."""
|
||||
response = await client.patch(
|
||||
"/api/v1/auth/me",
|
||||
json={"email": "newemail@example.com", "current_password": "WrongPassword123!"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_update_email_duplicate(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Email change to existing email returns 400."""
|
||||
# Register second user
|
||||
await client.post("/api/v1/auth/register", json={
|
||||
"email": "other@example.com",
|
||||
"password": "TestPassword123!",
|
||||
"name": "Other User",
|
||||
})
|
||||
|
||||
response = await client.patch(
|
||||
"/api/v1/auth/me",
|
||||
json={"email": "other@example.com", "current_password": "TestPassword123!"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already registered" in response.json()["detail"].lower()
|
||||
|
||||
async def test_get_me_returns_updated_name(self, client: AsyncClient, auth_headers: dict):
|
||||
"""GET /me reflects the updated profile."""
|
||||
await client.patch(
|
||||
"/api/v1/auth/me",
|
||||
json={"name": "Updated User"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
response = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated User"
|
||||
|
||||
async def test_no_changes_returns_current_user(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Empty update returns current user without error."""
|
||||
response = await client.patch(
|
||||
"/api/v1/auth/me",
|
||||
json={},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_unauthenticated(self, client: AsyncClient):
|
||||
"""Unauthenticated request returns 401."""
|
||||
response = await client.patch("/api/v1/auth/me", json={"name": "X"})
|
||||
assert response.status_code == 401
|
||||
57
backend/tests/test_email_verification.py
Normal file
57
backend/tests/test_email_verification.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Tests for email verification endpoints."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestEmailVerification:
|
||||
"""Test email verification send + verify flow."""
|
||||
|
||||
async def test_send_verification(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Send verification email returns 200."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/email/send-verification",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "sent" in response.json()["message"].lower()
|
||||
|
||||
async def test_send_verification_already_verified(
|
||||
self, client: AsyncClient, auth_headers: dict, test_db
|
||||
):
|
||||
"""Returns 400 if email is already verified."""
|
||||
from sqlalchemy import select, update
|
||||
from datetime import datetime, timezone
|
||||
from app.models.user import User
|
||||
|
||||
# Manually mark email as verified
|
||||
await test_db.execute(
|
||||
update(User).where(User.email == "test@example.com").values(
|
||||
email_verified_at=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/email/send-verification",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already verified" in response.json()["detail"].lower()
|
||||
|
||||
async def test_verify_invalid_token(self, client: AsyncClient):
|
||||
"""Invalid token returns 400."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/email/verify",
|
||||
json={"token": "invalid-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_verify_missing_token(self, client: AsyncClient):
|
||||
"""Missing token returns 400."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/email/verify",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1,7 +1,7 @@
|
||||
name: resolutionflow
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: resolutionflow_postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
|
||||
263
docs/plans/2026-02-20-frontend-standardization-prompt.md
Normal file
263
docs/plans/2026-02-20-frontend-standardization-prompt.md
Normal 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.
|
||||
368
docs/plans/2026-03-03-aesthetic-redesign-design.md
Normal file
368
docs/plans/2026-03-03-aesthetic-redesign-design.md
Normal 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 (Mon–Fri), 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.4–0.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.
|
||||
1400
docs/plans/2026-03-03-aesthetic-redesign-impl.md
Normal file
1400
docs/plans/2026-03-03-aesthetic-redesign-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
59
frontend/src/api/assistantChat.ts
Normal file
59
frontend/src/api/assistantChat.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
30
frontend/src/api/copilot.ts
Normal file
30
frontend/src/api/copilot.ts
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
92
frontend/src/components/account/DeleteAccountModal.tsx
Normal file
92
frontend/src/components/account/DeleteAccountModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DeleteAccountModal({ onClose }: Props) {
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await accountsApi.deleteAccount(password)
|
||||
await logout()
|
||||
navigate('/login')
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
setError(axiosErr.response?.data?.detail ?? 'Failed to delete account')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-6">
|
||||
<div className="flex items-center gap-2 text-rose-500 mb-4">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold font-heading text-foreground">Delete Account</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
This action is <strong className="text-rose-400">permanent</strong>. Your account, data,
|
||||
and all associated flows will be permanently deleted.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleDelete} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !password}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-semibold',
|
||||
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete Forever'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/account/LeaveAccountModal.tsx
Normal file
67
frontend/src/components/account/LeaveAccountModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Props {
|
||||
accountName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LeaveAccountModal({ accountName, onClose }: Props) {
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleLeave = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await accountsApi.leaveAccount()
|
||||
toast.success('You have left the account')
|
||||
await fetchUser()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail ?? 'Failed to leave account')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-6">
|
||||
<div className="flex items-center gap-2 text-amber-400 mb-4">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold font-heading text-foreground">Leave Account</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to leave <strong className="text-foreground">{accountName}</strong>?
|
||||
A new personal account will be created for you.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-semibold',
|
||||
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Leave Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/account/TransferOwnershipModal.tsx
Normal file
115
frontend/src/components/account/TransferOwnershipModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import type { AccountMember } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Props {
|
||||
members: AccountMember[]
|
||||
onClose: () => void
|
||||
onTransferred: () => void
|
||||
}
|
||||
|
||||
export function TransferOwnershipModal({ members, onClose, onTransferred }: Props) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const nonOwnerMembers = members.filter((m) => m.id !== user?.id)
|
||||
const [targetUserId, setTargetUserId] = useState(nonOwnerMembers[0]?.id ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!targetUserId || !password) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await accountsApi.transferOwnership(password, targetUserId)
|
||||
toast.success('Ownership transferred')
|
||||
onTransferred()
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
setError(axiosErr.response?.data?.detail ?? 'Transfer failed')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-6">
|
||||
<div className="flex items-center gap-2 text-amber-400 mb-4">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold font-heading text-foreground">Transfer Ownership</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
This will make the selected member the new account owner. You will become an engineer.
|
||||
</p>
|
||||
|
||||
{nonOwnerMembers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No other members to transfer to.</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">New Owner</label>
|
||||
<select
|
||||
value={targetUserId}
|
||||
onChange={(e) => setTargetUserId(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none'
|
||||
)}
|
||||
>
|
||||
{nonOwnerMembers.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name} ({m.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Your Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !password}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-semibold',
|
||||
'bg-amber-500 text-[#101114] hover:bg-amber-400',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Transfer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/assistant/ChatMessage.tsx
Normal file
52
frontend/src/components/assistant/ChatMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/assistant/ChatSidebar.tsx
Normal file
134
frontend/src/components/assistant/ChatSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/assistant/SuggestedFlowCard.tsx
Normal file
42
frontend/src/components/assistant/SuggestedFlowCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
180
frontend/src/components/copilot/CopilotPanel.tsx
Normal file
180
frontend/src/components/copilot/CopilotPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/copilot/CopilotToggle.tsx
Normal file
20
frontend/src/components/copilot/CopilotToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/dashboard/OpenSessions.tsx
Normal file
65
frontend/src/components/dashboard/OpenSessions.tsx
Normal 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))]">·</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>
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/dashboard/QuickActions.tsx
Normal file
41
frontend/src/components/dashboard/QuickActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
58
frontend/src/components/dashboard/RecentActivity.tsx
Normal file
58
frontend/src/components/dashboard/RecentActivity.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
91
frontend/src/components/dashboard/WeeklyCalendar.tsx
Normal file
91
frontend/src/components/dashboard/WeeklyCalendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/guides/GuideCard.tsx
Normal file
34
frontend/src/components/guides/GuideCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Guide } from '@/data/guides'
|
||||
|
||||
interface GuideCardProps {
|
||||
guide: Guide
|
||||
}
|
||||
|
||||
export function GuideCard({ guide }: GuideCardProps) {
|
||||
const Icon = guide.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/guides/${guide.slug}`}
|
||||
className="glass-card block rounded-2xl p-5 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Icon size={20} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-heading font-semibold text-foreground mb-1">
|
||||
{guide.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{guide.summary}
|
||||
</p>
|
||||
<span className="mt-2 inline-block font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
|
||||
{guide.sections.length} {guide.sections.length === 1 ? 'section' : 'sections'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/guides/GuideSection.tsx
Normal file
49
frontend/src/components/guides/GuideSection.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Lightbulb } from 'lucide-react'
|
||||
import type { GuideSection as GuideSectionType } from '@/data/guides'
|
||||
|
||||
interface GuideSectionProps {
|
||||
section: GuideSectionType
|
||||
index: number
|
||||
}
|
||||
|
||||
export function GuideSection({ section, index }: GuideSectionProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-base font-heading font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-bold text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
{section.title}
|
||||
</h3>
|
||||
<ol className="space-y-3 pl-8">
|
||||
{section.steps.map((step, i) => (
|
||||
<li key={i} className="relative">
|
||||
<span className="absolute -left-6 top-0.5 font-label text-[0.625rem] text-muted-foreground">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<p
|
||||
className="text-sm text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: step.instruction
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="text-foreground font-semibold">$1</strong>')
|
||||
}}
|
||||
/>
|
||||
{step.detail && (
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{step.detail}
|
||||
</p>
|
||||
)}
|
||||
{step.tip && (
|
||||
<div className="mt-2 flex items-start gap-2 rounded-lg bg-primary/5 border-l-2 border-primary px-3 py-2">
|
||||
<Lightbulb size={14} className="text-primary shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-semibold text-foreground">Tip:</span> {step.tip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { EmailVerificationBanner } from './EmailVerificationBanner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppLayout() {
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
57
frontend/src/components/layout/EmailVerificationBanner.tsx
Normal file
57
frontend/src/components/layout/EmailVerificationBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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
495
frontend/src/data/guides.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Rocket,
|
||||
Box,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
Play,
|
||||
Clock,
|
||||
Share2,
|
||||
Sparkles,
|
||||
BotMessageSquare,
|
||||
Bookmark,
|
||||
Wrench,
|
||||
Settings,
|
||||
BarChart3,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface GuideStep {
|
||||
instruction: string
|
||||
detail?: string
|
||||
tip?: string
|
||||
}
|
||||
|
||||
export interface GuideSection {
|
||||
title: string
|
||||
steps: GuideStep[]
|
||||
}
|
||||
|
||||
export interface Guide {
|
||||
slug: string
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
summary: string
|
||||
sections: GuideSection[]
|
||||
}
|
||||
|
||||
export const guides: Guide[] = [
|
||||
{
|
||||
slug: 'getting-started',
|
||||
title: 'Getting Started',
|
||||
icon: Rocket,
|
||||
summary: 'Account setup, first login, and navigating the app.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Logging In',
|
||||
steps: [
|
||||
{ instruction: 'Go to the ResolutionFlow login page and enter your email and password.' },
|
||||
{ instruction: 'Click **Sign In** to access your dashboard.' },
|
||||
{ instruction: 'If you forgot your password, click **Forgot password?** on the login page and follow the email instructions.', tip: 'Check your spam folder if you don\'t receive the reset email within a few minutes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Navigating the App',
|
||||
steps: [
|
||||
{ instruction: 'The **sidebar** on the left contains all main navigation links: Dashboard, All Flows, Flow Editor, Sessions, Exports, and more.' },
|
||||
{ instruction: 'The **top bar** has a search bar (Ctrl+K / Cmd+K) to quickly find flows, sessions, and tags.' },
|
||||
{ instruction: 'Click the **Quick Launch** (lightning bolt icon) in the top bar to start a flow without navigating to it first.' },
|
||||
{ instruction: 'Your **user avatar** in the top-right opens a menu for Account settings, Admin Panel (if applicable), and Logout.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Understanding the Dashboard',
|
||||
steps: [
|
||||
{ instruction: 'The Dashboard shows your active sessions, recent flows, and quick stats at a glance.' },
|
||||
{ instruction: 'Click any active session card to resume where you left off.' },
|
||||
{ instruction: 'Use the **Pinned Flows** section at the top of the sidebar for quick access to your most-used flows.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'creating-flows',
|
||||
title: 'Creating Flows',
|
||||
icon: Box,
|
||||
summary: 'Create troubleshooting, procedural, and maintenance flows.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Creating a Troubleshooting Flow',
|
||||
steps: [
|
||||
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
|
||||
{ instruction: 'Select **Troubleshooting** as the flow type.' },
|
||||
{ instruction: 'Enter a name and optional description for your flow.' },
|
||||
{ instruction: 'Click **Create** to open the canvas editor where you can build your decision tree.', tip: 'Choose a descriptive name like "DNS Resolution Failure" so your team can find it easily.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Creating a Procedural Flow (Project)',
|
||||
steps: [
|
||||
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
|
||||
{ instruction: 'Select **Procedural** as the flow type.' },
|
||||
{ instruction: 'Enter a name and description.' },
|
||||
{ instruction: 'Click **Create** to open the procedural editor where you can add steps, intake forms, and checklists.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Creating a Maintenance Flow',
|
||||
steps: [
|
||||
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
|
||||
{ instruction: 'Select **Maintenance** as the flow type.' },
|
||||
{ instruction: 'Enter a name and description.' },
|
||||
{ instruction: 'Click **Create**. Maintenance flows use the same step-based editor as procedural flows but support batch launches across multiple targets.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Managing Flow Properties',
|
||||
steps: [
|
||||
{ instruction: 'From the flow editor, click the flow name or settings area to update the name, description, category, and tags.' },
|
||||
{ instruction: 'Assign a **category** to organize flows by topic (e.g., "Networking", "Active Directory").' },
|
||||
{ instruction: 'Add **tags** for searchability (e.g., "DNS", "VPN", "Firewall").', tip: 'Tags are shared across your team. Use consistent naming so everyone can find relevant flows.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'tree-editor',
|
||||
title: 'Tree Editor (Canvas)',
|
||||
icon: GitBranch,
|
||||
summary: 'Build decision trees with nodes, options, actions, and solutions.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Understanding the Canvas',
|
||||
steps: [
|
||||
{ instruction: 'The canvas editor displays your troubleshooting flow as a visual decision tree.' },
|
||||
{ instruction: 'Each **node** represents a question, action, or solution in your troubleshooting path.' },
|
||||
{ instruction: 'Nodes are connected by **options** — the answers or choices that lead to the next step.' },
|
||||
{ instruction: 'Use the toolbar at the top to zoom, fit to screen, and access additional options.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Adding Nodes',
|
||||
steps: [
|
||||
{ instruction: 'Click the **+** button on any existing node to add a child node.' },
|
||||
{ instruction: 'Choose the node type: **Question** (asks the engineer something), **Action** (instructs them to do something), or **Solution** (the resolution).' },
|
||||
{ instruction: 'Type the node content — this is what the engineer will see during navigation.' },
|
||||
{ instruction: 'For Question nodes, add **options** (answers) that branch to different paths.', tip: 'Keep questions specific and actionable. "Is the DNS server responding to nslookup?" is better than "Check DNS".' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Editing Nodes',
|
||||
steps: [
|
||||
{ instruction: 'Click any node on the canvas to select it and open the edit panel.' },
|
||||
{ instruction: 'Update the node content, type, or options in the side panel.' },
|
||||
{ instruction: 'To delete a node, select it and click the **Delete** button or press the Delete key.' },
|
||||
{ instruction: 'Use **Undo** (Ctrl+Z) and **Redo** (Ctrl+Shift+Z) to revert changes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Solution Nodes',
|
||||
steps: [
|
||||
{ instruction: 'Solution nodes are endpoints — they represent the resolution to the troubleshooting path.' },
|
||||
{ instruction: 'Write clear, actionable solutions with specific commands or steps the engineer should follow.' },
|
||||
{ instruction: 'You can have multiple solution nodes for different resolution paths.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'procedural-editor',
|
||||
title: 'Procedural Flow Editor',
|
||||
icon: ListChecks,
|
||||
summary: 'Build step-by-step procedures with intake forms and checklists.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Adding Steps',
|
||||
steps: [
|
||||
{ instruction: 'In the procedural editor, click **Add Step** to add a new step to your flow.' },
|
||||
{ instruction: 'Enter the step title and detailed instructions.' },
|
||||
{ instruction: 'Steps execute in order from top to bottom. Drag steps to reorder them.' },
|
||||
{ instruction: 'Use **Section Headers** to group related steps under labeled sections.', tip: 'Break long procedures into sections like "Preparation", "Execution", and "Verification".' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Intake Forms',
|
||||
steps: [
|
||||
{ instruction: 'Intake forms collect information before the procedure begins (e.g., client name, server IP).' },
|
||||
{ instruction: 'Click **Add Field** in the intake form section to add a form field.' },
|
||||
{ instruction: 'Choose the field type: **Text**, **Textarea**, **Select** (dropdown), **Number**, **URL**, or **Checkbox**.' },
|
||||
{ instruction: 'Mark fields as **Required** if they must be filled before proceeding.' },
|
||||
{ instruction: 'Field values become **variables** you can reference in step instructions using the variable name.', tip: 'Use descriptive variable names like "client_name" or "server_ip" so they\'re easy to reference in steps.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Step Options',
|
||||
steps: [
|
||||
{ instruction: 'Expand **More Options** on any step to access additional settings.' },
|
||||
{ instruction: 'Add a **URL** field to link to relevant documentation or tools.' },
|
||||
{ instruction: 'Steps can include notes fields where engineers enter observations during execution.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'running-flows',
|
||||
title: 'Running Flows',
|
||||
icon: Play,
|
||||
summary: 'Navigate troubleshooting flows and execute procedural procedures.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Running a Troubleshooting Flow',
|
||||
steps: [
|
||||
{ instruction: 'Go to **All Flows** in the sidebar and find the flow you want to run.' },
|
||||
{ instruction: 'Click the flow card, then click **Start** to begin a new session.' },
|
||||
{ instruction: 'Read each question and select the answer that matches your situation.' },
|
||||
{ instruction: 'Follow the path until you reach a **Solution** node with the resolution steps.' },
|
||||
{ instruction: 'Use the **Scratchpad** (notepad icon) to take notes during navigation.', tip: 'You can pin frequently-used flows in the sidebar for quick access.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Running a Procedural Flow',
|
||||
steps: [
|
||||
{ instruction: 'Navigate to the procedural flow and click **Start**.' },
|
||||
{ instruction: 'Fill out the **Intake Form** with required information, then click **Begin**.' },
|
||||
{ instruction: 'Work through each step in order. Mark steps as complete using the checkbox.' },
|
||||
{ instruction: 'Add notes to individual steps as you work through them.' },
|
||||
{ instruction: 'The progress bar at the top shows your completion percentage.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Using Flow Assist (AI Copilot)',
|
||||
steps: [
|
||||
{ instruction: 'While navigating any flow, click the **Flow Assist** button (sparkles icon) in the bottom-right corner.' },
|
||||
{ instruction: 'Ask questions about the current step, like "What else could cause this?" or "How do I check this?"' },
|
||||
{ instruction: 'The AI understands your current position in the flow and provides contextual answers.' },
|
||||
{ instruction: 'If the AI finds related flows in your team\'s library, they appear as **Suggested Flows** cards you can click to open.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'sessions',
|
||||
title: 'Sessions',
|
||||
icon: Clock,
|
||||
summary: 'Session history, resuming, notes, and scratchpad.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Viewing Session History',
|
||||
steps: [
|
||||
{ instruction: 'Click **Sessions** in the sidebar to see all your past and active sessions.' },
|
||||
{ instruction: 'Sessions are listed newest first. Use the filters to show only active or completed sessions.' },
|
||||
{ instruction: 'Click any session to view its full details including the path taken and notes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Resuming a Session',
|
||||
steps: [
|
||||
{ instruction: 'Find the session in your session history or on the Dashboard.' },
|
||||
{ instruction: 'Click the session, then click **Resume** to continue where you left off.' },
|
||||
{ instruction: 'All previous decisions and notes are preserved.', tip: 'Active sessions also appear in the sidebar badge count for quick reference.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Session Notes & Scratchpad',
|
||||
steps: [
|
||||
{ instruction: 'During flow navigation, click the **Scratchpad** icon to open the note-taking panel.' },
|
||||
{ instruction: 'Type free-form notes about your troubleshooting process.' },
|
||||
{ instruction: 'Notes are saved automatically and included in session exports.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'sharing-exports',
|
||||
title: 'Sharing & Exports',
|
||||
icon: Share2,
|
||||
summary: 'Share sessions and export documentation.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Sharing a Session',
|
||||
steps: [
|
||||
{ instruction: 'Open a completed session from the session detail page.' },
|
||||
{ instruction: 'Click the **Share** button to open the sharing modal.' },
|
||||
{ instruction: 'A unique share link is generated. Click **Copy Link** to copy it to your clipboard.' },
|
||||
{ instruction: 'Share the link with team members or clients — they can view the session path and notes.' },
|
||||
{ instruction: 'Manage active share links from the **Exports** page in the sidebar.', tip: 'Share links respect your account\'s public sharing settings. Account owners can enable or disable public shares.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Exporting Sessions',
|
||||
steps: [
|
||||
{ instruction: 'From a session detail page, click the **Export** button.' },
|
||||
{ instruction: 'Choose the detail level: **Summary** (high-level overview), **Standard** (key decisions), or **Detailed** (full path with all notes).' },
|
||||
{ instruction: 'Preview the export and edit it if needed before downloading.' },
|
||||
{ instruction: 'Enable **Sensitive Data Redaction** to automatically mask passwords, IPs, and credentials in the export.', tip: 'Use the summary export for client-facing documentation and the detailed export for internal records.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Managing Shares',
|
||||
steps: [
|
||||
{ instruction: 'Click **Exports** in the sidebar to see all your shared sessions.' },
|
||||
{ instruction: 'View how many times each share link has been accessed.' },
|
||||
{ instruction: 'Revoke share links by clicking the delete icon next to any active share.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'ai-assistant',
|
||||
title: 'AI Assistant',
|
||||
icon: BotMessageSquare,
|
||||
summary: 'Standalone AI chat for IT questions and flow recommendations.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Starting a Conversation',
|
||||
steps: [
|
||||
{ instruction: 'Click **AI Assistant** in the sidebar to open the chat page.' },
|
||||
{ instruction: 'Click **Start a Conversation** or the **+ New Chat** button in the left panel.' },
|
||||
{ instruction: 'Type your question in the message box and press Enter or click the send button.' },
|
||||
{ instruction: 'The AI responds as a Senior Systems & Network Engineer with MSP expertise.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Managing Conversations',
|
||||
steps: [
|
||||
{ instruction: 'All conversations are listed in the left sidebar panel, newest first.' },
|
||||
{ instruction: 'Click any conversation to switch to it and see the full message history.' },
|
||||
{ instruction: '**Pin** important conversations by right-clicking or using the pin icon — pinned chats stay at the top.' },
|
||||
{ instruction: 'Delete conversations you no longer need by clicking the trash icon.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Suggested Flows',
|
||||
steps: [
|
||||
{ instruction: 'When you ask a question, the AI searches your team\'s flow library for relevant matches.' },
|
||||
{ instruction: 'If related flows are found, they appear as **Suggested Flow** cards below the AI response.' },
|
||||
{ instruction: 'Click a suggested flow card to navigate directly to that flow.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'ai-copilot',
|
||||
title: 'Flow Assist (AI Copilot)',
|
||||
icon: Sparkles,
|
||||
summary: 'In-session AI help while navigating flows.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Opening Flow Assist',
|
||||
steps: [
|
||||
{ instruction: 'While navigating any flow, look for the **Flow Assist** button (sparkles icon) in the bottom-right corner of the screen.' },
|
||||
{ instruction: 'Click it to open the AI assistant panel on the right side.' },
|
||||
{ instruction: 'The AI automatically knows which flow you\'re in and what step you\'re on.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Asking Questions',
|
||||
steps: [
|
||||
{ instruction: 'Type your question in the message box at the bottom of the panel.' },
|
||||
{ instruction: 'Ask things like "What else could cause this?", "How do I run this command?", or "Explain this step in more detail."' },
|
||||
{ instruction: 'The AI provides contextual answers based on your current position in the flow.' },
|
||||
{ instruction: 'Your conversation persists throughout the session — you can refer back to earlier answers.', tip: 'Flow Assist is especially useful when you encounter an unfamiliar step or need additional troubleshooting guidance.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Suggested Flows',
|
||||
steps: [
|
||||
{ instruction: 'If your question relates to other flows in your team\'s library, the AI shows **Related Flows** cards.' },
|
||||
{ instruction: 'Click a card to open that flow in a new tab.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'step-library',
|
||||
title: 'Step Library',
|
||||
icon: Bookmark,
|
||||
summary: 'Reusable steps you can import into any procedural flow.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Browsing the Step Library',
|
||||
steps: [
|
||||
{ instruction: 'Click **Step Library** in the sidebar to view all saved reusable steps.' },
|
||||
{ instruction: 'Steps are organized by category and can be searched by name or tags.' },
|
||||
{ instruction: 'Click any step to view its full details and instructions.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Saving Steps to the Library',
|
||||
steps: [
|
||||
{ instruction: 'In the procedural flow editor, click the **Save to Library** option on any step.' },
|
||||
{ instruction: 'Give the library step a name and optional category.' },
|
||||
{ instruction: 'The step is now available for reuse across all your procedural and maintenance flows.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Importing Library Steps',
|
||||
steps: [
|
||||
{ instruction: 'In the procedural flow editor, click **Import from Library** when adding a new step.' },
|
||||
{ instruction: 'Browse or search the step library for the step you want.' },
|
||||
{ instruction: 'Click **Import** to add it to your flow. The imported step is a copy — editing it won\'t affect the library version.', tip: 'Use the step library for common procedures like "Verify backup status" or "Check DNS resolution" that appear across multiple flows.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'maintenance',
|
||||
title: 'Maintenance Flows',
|
||||
icon: Wrench,
|
||||
summary: 'Batch launches, target lists, and scheduled maintenance.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Setting Up a Maintenance Flow',
|
||||
steps: [
|
||||
{ instruction: 'Create a new flow and select **Maintenance** as the type.' },
|
||||
{ instruction: 'Build your steps in the procedural editor — these are the maintenance tasks to perform on each target.' },
|
||||
{ instruction: 'The flow detail page shows maintenance-specific options including batch launches and scheduling.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Target Lists',
|
||||
steps: [
|
||||
{ instruction: 'Go to **Account** > **Target Lists** to manage your saved target lists.' },
|
||||
{ instruction: 'Create a target list with the servers, workstations, or devices you maintain.' },
|
||||
{ instruction: 'Target lists can be reused across multiple maintenance flows and batch launches.', tip: 'Organize target lists by client or site for easy batch launches (e.g., "Acme Corp - Domain Controllers").' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Batch Launching',
|
||||
steps: [
|
||||
{ instruction: 'Open a maintenance flow and click **Launch Batch**.' },
|
||||
{ instruction: 'Select a saved **Target List** or manually enter targets.' },
|
||||
{ instruction: 'Click **Launch** to create a session for each target in the list.' },
|
||||
{ instruction: 'All sessions are created immediately. Click into any target to begin executing the maintenance steps.' },
|
||||
{ instruction: 'Track progress on the **Batch Status** page showing completion status across all targets.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Scheduling',
|
||||
steps: [
|
||||
{ instruction: 'On the maintenance flow detail page, click **Schedule** to set up recurring execution.' },
|
||||
{ instruction: 'Choose a schedule (e.g., weekly, monthly) using the cron expression builder.' },
|
||||
{ instruction: 'Select the target list to use for each scheduled run.' },
|
||||
{ instruction: 'Scheduled batches are launched automatically at the configured time.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'account-settings',
|
||||
title: 'Account Settings',
|
||||
icon: Settings,
|
||||
summary: 'Team management, categories, tags, and profile settings.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Profile Settings',
|
||||
steps: [
|
||||
{ instruction: 'Click your **avatar** in the top-right corner and select **Account**.' },
|
||||
{ instruction: 'Click **Profile Settings** to update your display name, email, and password.' },
|
||||
{ instruction: 'Changes take effect immediately after saving.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Team Categories',
|
||||
steps: [
|
||||
{ instruction: 'Go to **Account** and click **Team Categories** (account owner only).' },
|
||||
{ instruction: 'Add categories to organize your team\'s flows (e.g., "Networking", "Security", "Cloud").' },
|
||||
{ instruction: 'Assign colors to categories for visual distinction in the flow library.' },
|
||||
{ instruction: 'Delete or rename categories as your team\'s needs evolve.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Chat Retention',
|
||||
steps: [
|
||||
{ instruction: 'Go to **Account** and click **Chat Retention** (account owner only).' },
|
||||
{ instruction: 'Set the **retention period** (default: 90 days) — chats older than this are automatically deleted.' },
|
||||
{ instruction: 'Set the **maximum conversation count** (default: 100) — oldest chats are deleted when the limit is exceeded.' },
|
||||
{ instruction: 'Pinned chats are never automatically deleted.', tip: 'Pin important AI conversations to preserve them regardless of retention settings.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'analytics',
|
||||
title: 'Analytics',
|
||||
icon: BarChart3,
|
||||
summary: 'Dashboard metrics, team usage, and personal stats.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Team Analytics',
|
||||
steps: [
|
||||
{ instruction: 'Click **Analytics** in the sidebar to view team-wide metrics.' },
|
||||
{ instruction: 'See total flows, active sessions, completion rates, and usage trends.' },
|
||||
{ instruction: 'Filter by date range to analyze specific periods.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Personal Analytics',
|
||||
steps: [
|
||||
{ instruction: 'From the Analytics page, click **My Stats** to see your individual metrics.' },
|
||||
{ instruction: 'Track your session count, most-used flows, and average completion time.' },
|
||||
{ instruction: 'Use personal analytics to identify areas where you spend the most troubleshooting time.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">→</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">→</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>
|
||||
)
|
||||
}
|
||||
|
||||
230
frontend/src/pages/AssistantChatPage.tsx
Normal file
230
frontend/src/pages/AssistantChatPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/pages/GuideDetailPage.tsx
Normal file
78
frontend/src/pages/GuideDetailPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { ChevronRight, ArrowLeft } from 'lucide-react'
|
||||
import { guides } from '@/data/guides'
|
||||
import { GuideSection } from '@/components/guides/GuideSection'
|
||||
|
||||
export default function GuideDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const guide = guides.find(g => g.slug === slug)
|
||||
|
||||
if (!guide) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">Guide Not Found</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">The guide you're looking for doesn't exist.</p>
|
||||
<Link
|
||||
to="/guides"
|
||||
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Back to Guides
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Icon = guide.icon
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground mb-6">
|
||||
<Link to="/guides" className="hover:text-primary transition-colors">
|
||||
User Guides
|
||||
</Link>
|
||||
<ChevronRight size={12} />
|
||||
<span className="text-foreground">{guide.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="glass-card-static rounded-2xl p-6 mb-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Icon size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-heading font-bold text-foreground">{guide.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{guide.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{guide.sections.length} {guide.sections.length === 1 ? 'section' : 'sections'}
|
||||
</span>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{guide.sections.reduce((acc, s) => acc + s.steps.length, 0)} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="glass-card-static rounded-2xl p-6">
|
||||
{guide.sections.map((section, i) => (
|
||||
<GuideSection key={i} section={section} index={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/guides"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to all guides
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/GuidesHubPage.tsx
Normal file
29
frontend/src/pages/GuidesHubPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import { guides } from '@/data/guides'
|
||||
import { GuideCard } from '@/components/guides/GuideCard'
|
||||
|
||||
export default function GuidesHubPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<BookOpen size={20} className="text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">User Guides</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-[52px]">
|
||||
Learn how to use ResolutionFlow with step-by-step instructions for every feature.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Guide cards grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{guides.map(guide => (
|
||||
<GuideCard key={guide.slug} guide={guide} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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's what'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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
75
frontend/src/pages/VerifyEmailPage.tsx
Normal file
75
frontend/src/pages/VerifyEmailPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, Link } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setErrorMessage('No verification token provided')
|
||||
return
|
||||
}
|
||||
|
||||
authApi.verifyEmail(token)
|
||||
.then(() => setStatus('success'))
|
||||
.catch((err) => {
|
||||
setStatus('error')
|
||||
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
setErrorMessage(detail ?? 'Verification failed')
|
||||
})
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-8 text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-foreground">Verifying your email...</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-400" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Email Verified</h1>
|
||||
<p className="mt-2 text-muted-foreground">Your email has been successfully verified.</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-[10px] bg-gradient-brand px-6 py-2 text-sm font-semibold text-[#101114]',
|
||||
'shadow-lg shadow-primary/20 hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="mx-auto h-12 w-12 text-rose-500" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Verification Failed</h1>
|
||||
<p className="mt-2 text-muted-foreground">{errorMessage}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-6 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VerifyEmailPage
|
||||
119
frontend/src/pages/account/ChatRetentionSettingsPage.tsx
Normal file
119
frontend/src/pages/account/ChatRetentionSettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
frontend/src/pages/account/ProfileSettingsPage.tsx
Normal file
184
frontend/src/pages/account/ProfileSettingsPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { UserUpdate } from '@/types'
|
||||
|
||||
const inputClass = cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)
|
||||
|
||||
export function ProfileSettingsPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||
|
||||
const [name, setName] = useState(user?.name ?? '')
|
||||
const [email, setEmail] = useState(user?.email ?? '')
|
||||
const [phone, setPhone] = useState(user?.phone ?? '')
|
||||
const [jobTitle, setJobTitle] = useState(user?.job_title ?? '')
|
||||
const [timezone, setTimezone] = useState(user?.timezone ?? 'UTC')
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const emailChanged = email !== user?.email
|
||||
const hasChanges =
|
||||
emailChanged ||
|
||||
name !== user?.name ||
|
||||
phone !== (user?.phone ?? '') ||
|
||||
jobTitle !== (user?.job_title ?? '') ||
|
||||
timezone !== (user?.timezone ?? 'UTC')
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!hasChanges) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload: UserUpdate = {}
|
||||
if (name !== user?.name) payload.name = name.trim()
|
||||
if (emailChanged) {
|
||||
payload.email = email.trim()
|
||||
payload.current_password = currentPassword
|
||||
}
|
||||
if (phone !== (user?.phone ?? '')) payload.phone = phone.trim() || null
|
||||
if (jobTitle !== (user?.job_title ?? '')) payload.job_title = jobTitle.trim() || null
|
||||
if (timezone !== (user?.timezone ?? 'UTC')) payload.timezone = timezone
|
||||
|
||||
await authApi.updateProfile(payload)
|
||||
await fetchUser()
|
||||
setCurrentPassword('')
|
||||
toast.success('Profile updated')
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
setError(axiosErr.response?.data?.detail ?? 'Failed to update profile')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserIcon className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Profile Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Update your name, email, and personal details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<form onSubmit={handleSave} className="glass-card-static p-6 space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="profile-name" className="block text-sm font-medium text-foreground">Name</label>
|
||||
<input id="profile-name" type="text" value={name} onChange={(e) => setName(e.target.value)} required className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="profile-email" className="block text-sm font-medium text-foreground">Email</label>
|
||||
<input id="profile-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Password confirmation for email change */}
|
||||
{emailChanged && (
|
||||
<div>
|
||||
<label htmlFor="profile-password" className="block text-sm font-medium text-foreground">Current Password</label>
|
||||
<p className="text-xs text-muted-foreground">Required to change your email address</p>
|
||||
<input id="profile-password" type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label htmlFor="profile-phone" className="block text-sm font-medium text-foreground">Phone</label>
|
||||
<input id="profile-phone" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Optional" className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Job Title */}
|
||||
<div>
|
||||
<label htmlFor="profile-job-title" className="block text-sm font-medium text-foreground">Job Title</label>
|
||||
<input id="profile-job-title" type="text" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} placeholder="e.g. Network Engineer" className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div>
|
||||
<label htmlFor="profile-timezone" className="block text-sm font-medium text-foreground">Timezone</label>
|
||||
<select id="profile-timezone" value={timezone} onChange={(e) => setTimezone(e.target.value)} className={inputClass}>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-rose-500">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !hasChanges}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114]',
|
||||
'shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to="/change-password"
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
Change Password
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'Pacific/Honolulu',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Amsterdam',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Dubai',
|
||||
'Australia/Sydney',
|
||||
'Australia/Melbourne',
|
||||
'Pacific/Auckland',
|
||||
]
|
||||
|
||||
export default ProfileSettingsPage
|
||||
@@ -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>
|
||||
|
||||
@@ -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: (
|
||||
|
||||
37
frontend/src/types/assistant-chat.ts
Normal file
37
frontend/src/types/assistant-chat.ts
Normal 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 }
|
||||
41
frontend/src/types/copilot.ts
Normal file
41
frontend/src/types/copilot.ts
Normal 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
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,12 @@ import path from 'path'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
Reference in New Issue
Block a user