feat: add account management, email verification, AI fixes, and user guides

- Profile settings, account transfer, delete/leave account flows
- Email verification with JWT tokens and Resend integration
- AI assistant/copilot fixes: markdown rendering, shared RAG helpers,
  token tracking, input refocus, model_validate usage
- User guides hub + detail pages with 13 topic guides
- Sidebar and top bar navigation for guides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-04 19:18:06 -05:00
parent 1aa60dada2
commit 8d6accaf60
45 changed files with 2255 additions and 126 deletions

View File

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

View File

@@ -1,5 +1,5 @@
import apiClient from './client'
import type { Token, User, UserCreate, UserLogin } from '@/types'
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types'
export const authApi = {
async register(data: UserCreate): Promise<User> {
@@ -53,6 +53,19 @@ export const authApi = {
new_password: newPassword,
})
},
async updateProfile(data: UserUpdate): Promise<User> {
const response = await apiClient.patch<User>('/auth/me', data)
return response.data
},
async sendVerificationEmail(): Promise<void> {
await apiClient.post('/auth/email/send-verification')
},
async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token })
},
}
export default authApi

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Sparkles, User } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { SuggestedFlowCard } from './SuggestedFlowCard'
import type { SuggestedFlow } from '@/types/copilot'
@@ -31,7 +32,7 @@ export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps)
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
}`}
>
<div className="whitespace-pre-wrap">{content}</div>
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
</div>
{/* Suggested flows (assistant only) */}

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { X, Send, Sparkles, Loader2 } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { copilotApi } from '@/api/copilot'
import { SuggestedFlowCard } from '@/components/assistant/SuggestedFlowCard'
import type { CopilotMessage, SuggestedFlow } from '@/types/copilot'
@@ -20,21 +21,9 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
const [loading, setLoading] = useState(false)
const [initializing, setInitializing] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
// Start conversation when panel opens
useEffect(() => {
if (isOpen && !conversationId && !initializing) {
startConversation()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const startConversation = async () => {
const startConversation = useCallback(async () => {
setInitializing(true)
try {
const response = await copilotApi.startConversation({
@@ -49,7 +38,19 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
} finally {
setInitializing(false)
}
}
}, [treeId, sessionId, currentNodeId])
// Start conversation when panel opens or treeId changes
useEffect(() => {
if (isOpen && !conversationId && !initializing) {
startConversation()
}
}, [isOpen, treeId, startConversation, conversationId, initializing])
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = async () => {
if (!input.trim() || !conversationId || loading) return
@@ -72,6 +73,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
setMessages(prev => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }])
} finally {
setLoading(false)
requestAnimationFrame(() => inputRef.current?.focus())
}
}
@@ -123,7 +125,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
}`}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
</div>
</div>
))}
@@ -154,6 +156,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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