feat: post-PR-159 UI cleanup — sidebar IA + account redesign #160
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
## Recently shipped (post-0.1.0.0)
|
## Recently shipped (post-0.1.0.0)
|
||||||
|
|
||||||
|
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Heuristic score 24/40 → 33/40 across five sub-passes (distill, quieter, layout, typeset, polish). Removed duplicate "Suggested checks" chip strip → TaskLane is the single source of truth; added inline `Next steps · N pending` cue on the latest action-bearing AI bubble; consolidated session header to Resolve + Escalate + ⋯ kebab; centered messages column to match composer; dropped all banned decorations (side stripes, gradient surfaces, backdrop blur, accent borderTop) for a single decoration channel per surface; unified 14 text sizes into a 5-step scale. TaskLane keyboard flow: Enter submits + auto-advances, Shift+Enter newline, Esc cancel, focus jumps to Send after the last task. Banner ↔ script-panel are now linked (collapse hides both, any outcome closes both). WhatWeKnow section is collapsible with `sessionStorage` memory + auto-collapse-at-5-facts. Side fix: ParameterizationPreview no longer over-highlights short parameter values (word-boundary check). Two backlog entries logged in `.ai/TODO.md`: ConcludeSessionModal multi-select and `bg-card-hover` Tailwind drift in CommandPalette.
|
||||||
- **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint).
|
- **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint).
|
||||||
- **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push.
|
- **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push.
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test.describe('authenticated navigation smoke tests', () => {
|
|||||||
await page.goto('/account')
|
await page.goto('/account')
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Account Management' }),
|
page.getByRole('heading', { name: 'Settings' }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPoi
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
LayoutGrid, Clock, AlertTriangle, GitBranch,
|
||||||
ListChecks, Download, BarChart3,
|
ListChecks, BarChart3,
|
||||||
Settings, Pin, PinOff,
|
Settings, Pin, PinOff,
|
||||||
History, FileText, Network, Ticket,
|
FileText, Ticket, BookOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
@@ -31,11 +31,6 @@ interface NavEntry {
|
|||||||
children?: NavSubItem[]
|
children?: NavSubItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavSection {
|
|
||||||
title: string
|
|
||||||
items: NavEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sidebar component ──────────────────────────────── */
|
/* ── Sidebar component ──────────────────────────────── */
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@@ -78,36 +73,40 @@ export function Sidebar() {
|
|||||||
|
|
||||||
/* ── Navigation data ──────────────────────────────── */
|
/* ── Navigation data ──────────────────────────────── */
|
||||||
|
|
||||||
/* ── Grouped nav: 5 top-level icons (Sentry-style) ── */
|
/* Single source-of-truth IA. Same items, same order, in both rail
|
||||||
|
* and pinned modes. Pin/unpin is a width/label affordance, not an
|
||||||
|
* IA switch. A hairline divider separates the two groups; no labels. */
|
||||||
|
|
||||||
const railGroups: NavEntry[] = [
|
const workItems: NavEntry[] = [
|
||||||
{
|
{
|
||||||
href: '/', icon: LayoutGrid, label: 'Home', shortLabel: 'Home',
|
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
|
||||||
matchPaths: ['/'],
|
matchPaths: ['/'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: '/sessions', icon: History, label: 'History', shortLabel: 'History',
|
|
||||||
badge: stats?.active_count || undefined,
|
|
||||||
matchPaths: ['/sessions', '/escalations', '/pilot'],
|
|
||||||
children: [
|
|
||||||
{ href: '/sessions', label: 'Session History', count: stats?.active_count || undefined },
|
|
||||||
{ href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
|
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
|
||||||
matchPaths: ['/tickets'],
|
matchPaths: ['/tickets'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/sessions', icon: Clock, label: 'Sessions', shortLabel: 'Sessions',
|
||||||
|
badge: stats?.active_count || undefined,
|
||||||
|
matchPaths: ['/sessions'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal',
|
||||||
|
badge: stats?.escalation_count || undefined,
|
||||||
|
matchPaths: ['/escalations'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const libraryItems: NavEntry[] = [
|
||||||
{
|
{
|
||||||
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
||||||
badge: stats?.tree_counts.total || undefined,
|
badge: stats?.tree_counts.total || undefined,
|
||||||
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue', '/network-diagrams'],
|
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/network-diagrams'],
|
||||||
children: [
|
children: [
|
||||||
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
|
|
||||||
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
||||||
{ href: '/network-diagrams', label: 'Network Maps' },
|
|
||||||
{ href: '/step-library', label: 'Solutions Library' },
|
{ href: '/step-library', label: 'Solutions Library' },
|
||||||
{ href: '/review-queue', label: 'Review Queue' },
|
{ href: '/network-diagrams', label: 'Network Maps' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,60 +114,25 @@ export function Sidebar() {
|
|||||||
badge: pendingDraftCount || undefined,
|
badge: pendingDraftCount || undefined,
|
||||||
matchPaths: ['/scripts', '/script-builder'],
|
matchPaths: ['/scripts', '/script-builder'],
|
||||||
children: [
|
children: [
|
||||||
{ href: '/scripts', label: 'Script Library', count: pendingDraftCount || undefined },
|
|
||||||
{ href: '/script-builder', label: 'Script Builder' },
|
{ href: '/script-builder', label: 'Script Builder' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/analytics', icon: BarChart3, label: 'Insights', shortLabel: 'Data',
|
href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review',
|
||||||
|
matchPaths: ['/review-queue'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats',
|
||||||
matchPaths: ['/analytics', '/shares'],
|
matchPaths: ['/analytics', '/shares'],
|
||||||
children: [
|
children: [
|
||||||
{ href: '/analytics', label: 'Analytics' },
|
|
||||||
{ href: '/shares', label: 'Exports' },
|
{ href: '/shares', label: 'Exports' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/* Pinned mode still uses the detailed section layout */
|
|
||||||
const sections: NavSection[] = [
|
|
||||||
{
|
|
||||||
title: 'RESOLVE',
|
|
||||||
items: [
|
|
||||||
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
|
|
||||||
{ href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
|
|
||||||
{ href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] },
|
|
||||||
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'KNOWLEDGE',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
href: '/trees', icon: GitBranch, label: 'Flow Library', shortLabel: 'Flows',
|
|
||||||
badge: stats?.tree_counts.total || undefined,
|
|
||||||
matchPaths: ['/trees', '/flows', '/my-trees'],
|
|
||||||
children: [
|
|
||||||
{ href: '/trees', label: 'Flow Library' },
|
|
||||||
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },
|
|
||||||
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
|
|
||||||
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
|
|
||||||
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'INSIGHTS',
|
|
||||||
items: [
|
|
||||||
{ href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats' },
|
|
||||||
{ href: '/shares', icon: Download, label: 'Exports', shortLabel: 'Export' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const footerItems: NavEntry[] = [
|
const footerItems: NavEntry[] = [
|
||||||
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct' },
|
{ href: '/guides', icon: BookOpen, label: 'Guides', shortLabel: 'Guides', matchPaths: ['/guides'] },
|
||||||
|
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct', matchPaths: ['/account'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
/* ── Active detection ─────────────────────────────── */
|
/* ── Active detection ─────────────────────────────── */
|
||||||
@@ -369,9 +333,9 @@ export function Sidebar() {
|
|||||||
|
|
||||||
/* ── Find active flyout group for drawer ── */
|
/* ── Find active flyout group for drawer ── */
|
||||||
|
|
||||||
|
const allRailItems = [...workItems, ...libraryItems, ...footerItems]
|
||||||
const activeFlyoutGroup = flyoutIndex && !sidebarPinned
|
const activeFlyoutGroup = flyoutIndex && !sidebarPinned
|
||||||
? railGroups.find((_, i) => `rail-${i}` === flyoutIndex) ||
|
? allRailItems.find(item => item.href === flyoutIndex) || null
|
||||||
footerItems.find((_, i) => `footer-${i}` === flyoutIndex)
|
|
||||||
: null
|
: null
|
||||||
|
|
||||||
/* ── Main render ──────────────────────────────────── */
|
/* ── Main render ──────────────────────────────────── */
|
||||||
@@ -386,23 +350,20 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{/* Pinned sidebar content */}
|
{/* Pinned sidebar content */}
|
||||||
<div className="px-3 py-2 space-y-0.5">
|
<div className="px-3 py-2 space-y-0.5">
|
||||||
{sections.map((section, si) => (
|
{workItems.map(item => renderPinnedItem(item, item.href))}
|
||||||
<div key={section.title}>
|
<div
|
||||||
{si > 0 && (
|
className="my-3 border-t"
|
||||||
<div className="font-mono text-[0.5625rem] uppercase tracking-[0.12em] text-text-muted px-3 pt-3 pb-1">
|
style={{ borderColor: 'var(--color-border-default)' }}
|
||||||
{section.title}
|
aria-hidden="true"
|
||||||
</div>
|
/>
|
||||||
)}
|
{libraryItems.map(item => renderPinnedItem(item, item.href))}
|
||||||
{section.items.map((item, ii) => renderPinnedItem(item, `${si}-${ii}`))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-3 pt-2 pb-4 space-y-0.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
<div className="px-3 pt-2 pb-4 space-y-0.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
||||||
{footerItems.map((item, i) => renderPinnedItem(item, `footer-${i}`))}
|
{footerItems.map(item => renderPinnedItem(item, item.href))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSidebarPinned}
|
onClick={toggleSidebarPinned}
|
||||||
@@ -417,7 +378,7 @@ export function Sidebar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon Rail (default) — 5 grouped icons, Sentry-style */
|
/* Icon rail (default, unpinned) — same items as pinned mode, narrower. */
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full"
|
className="flex h-full"
|
||||||
@@ -432,14 +393,20 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{/* Nav items */}
|
{/* Nav items */}
|
||||||
<div className="flex flex-col items-center w-full px-1 space-y-1.5">
|
<div className="flex flex-col items-center w-full px-1 space-y-1.5">
|
||||||
{railGroups.map((item, i) => renderRailItem(item, `rail-${i}`))}
|
{workItems.map(item => renderRailItem(item, item.href))}
|
||||||
|
<div
|
||||||
|
className="w-8 my-1 border-t self-center"
|
||||||
|
style={{ borderColor: 'var(--color-border-default)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{libraryItems.map(item => renderRailItem(item, item.href))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Footer: Account + Pin */}
|
{/* Footer: Guides, Account + Pin */}
|
||||||
<div className="flex flex-col items-center w-full px-1 pb-5 pt-3 space-y-1.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
<div className="flex flex-col items-center w-full px-1 pb-5 pt-3 space-y-1.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
||||||
{footerItems.map((item, i) => renderRailItem(item, `footer-${i}`))}
|
{footerItems.map(item => renderRailItem(item, item.href))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSidebarPinned}
|
onClick={toggleSidebarPinned}
|
||||||
@@ -471,7 +438,7 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{/* Drawer header */}
|
{/* Drawer header */}
|
||||||
<div className="px-3 mb-3">
|
<div className="px-3 mb-3">
|
||||||
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-[#fbbf24]">
|
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-text-muted">
|
||||||
{activeFlyoutGroup.label}
|
{activeFlyoutGroup.label}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Building2,
|
|
||||||
Check,
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -14,13 +12,11 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Palette,
|
Palette,
|
||||||
|
Pencil,
|
||||||
Plug,
|
Plug,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
|
||||||
ShieldCheck,
|
|
||||||
UserCog,
|
UserCog,
|
||||||
Users,
|
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
@@ -36,46 +32,20 @@ import { cn } from '@/lib/utils'
|
|||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useSubscription } from '@/hooks/useSubscription'
|
import { useSubscription } from '@/hooks/useSubscription'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|
||||||
import { CheckoutButton } from '@/components/subscription/CheckoutButton'
|
import { CheckoutButton } from '@/components/subscription/CheckoutButton'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
interface SettingsLinkCardProps {
|
/* ── Building blocks ─────────────────────────────────────────────────────── */
|
||||||
to: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
badge?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsLinkCard({ to, icon, title, description, badge }: SettingsLinkCardProps) {
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<h2 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-text-muted">
|
||||||
to={to}
|
{children}
|
||||||
className="group rounded-2xl border border-border bg-card p-5 transition-colors hover:border-border-hover"
|
</h2>
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="rounded-xl bg-elevated p-2 text-muted-foreground">{icon}</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
|
||||||
{badge && (
|
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsageStat({
|
function UsageRow({
|
||||||
label,
|
label,
|
||||||
current,
|
current,
|
||||||
max,
|
max,
|
||||||
@@ -86,35 +56,68 @@ function UsageStat({
|
|||||||
}) {
|
}) {
|
||||||
const isUnlimited = max === null
|
const isUnlimited = max === null
|
||||||
const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100)
|
const percentage = isUnlimited ? 0 : Math.min((current / max) * 100, 100)
|
||||||
const isNearLimit = !isUnlimited && percentage >= 80
|
|
||||||
const isAtLimit = !isUnlimited && current >= max
|
const isAtLimit = !isUnlimited && current >= max
|
||||||
|
const isNearLimit = !isUnlimited && percentage >= 80
|
||||||
|
|
||||||
|
const numeralColor = isAtLimit
|
||||||
|
? 'text-danger'
|
||||||
|
: isNearLimit
|
||||||
|
? 'text-warning'
|
||||||
|
: 'text-foreground'
|
||||||
|
const barColor = isAtLimit ? 'bg-danger' : isNearLimit ? 'bg-warning' : 'bg-primary'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
<div className="grid grid-cols-[8rem_1fr_auto] items-center gap-4 py-1.5">
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">{label}</p>
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
<p
|
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
{!isUnlimited && (
|
||||||
|
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${percentage}%` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm tabular-nums">
|
||||||
|
<span className={numeralColor}>{current}</span>
|
||||||
|
<span className="text-muted-foreground"> / {isUnlimited ? '∞' : max}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsRowProps {
|
||||||
|
to: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status?: { label: string; tone: 'positive' | 'neutral' | 'warning' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsRow({ to, icon, title, description, status }: SettingsRowProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-2 text-xl font-semibold',
|
'group flex items-center gap-3 py-2.5 -mx-2 px-2 rounded-md',
|
||||||
isAtLimit ? 'text-danger' : isNearLimit ? 'text-warning' : 'text-foreground'
|
'transition-colors hover:bg-card-hover'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{current}
|
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||||
<span className="ml-1 text-sm font-normal text-muted-foreground">
|
<div className="min-w-0 flex-1">
|
||||||
/ {isUnlimited ? 'Unlimited' : max}
|
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||||
</span>
|
<div className="text-xs text-muted-foreground truncate">{description}</div>
|
||||||
</p>
|
</div>
|
||||||
{!isUnlimited && (
|
{status && (
|
||||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
<span
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full rounded-full transition-all',
|
'rounded-full px-2 py-0.5 text-[11px] font-medium shrink-0',
|
||||||
isAtLimit ? 'bg-danger' : isNearLimit ? 'bg-warning' : 'bg-primary'
|
status.tone === 'positive' && 'bg-success-dim text-success',
|
||||||
|
status.tone === 'neutral' && 'bg-muted text-muted-foreground',
|
||||||
|
status.tone === 'warning' && 'bg-warning-dim text-warning'
|
||||||
)}
|
)}
|
||||||
style={{ width: `${percentage}%` }}
|
>
|
||||||
/>
|
{status.label}
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<ArrowRight className="h-4 w-4 text-muted-foreground/50 transition-all group-hover:translate-x-0.5 group-hover:text-foreground" />
|
||||||
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +126,15 @@ function formatShortDate(value: string | null | undefined) {
|
|||||||
return new Date(value).toLocaleDateString()
|
return new Date(value).toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function planLabel(plan: string) {
|
||||||
|
return plan.charAt(0).toUpperCase() + plan.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AccountSettingsPage() {
|
export function AccountSettingsPage() {
|
||||||
const { isAccountOwner } = usePermissions()
|
const { isAccountOwner } = usePermissions()
|
||||||
const { plan, limits, usage } = useSubscription()
|
const { plan, limits, usage } = useSubscription()
|
||||||
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
|
|
||||||
const subscription = useAuthStore((s) => s.subscription)
|
const subscription = useAuthStore((s) => s.subscription)
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const refreshUser = useAuthStore((s) => s.fetchUser)
|
const refreshUser = useAuthStore((s) => s.fetchUser)
|
||||||
@@ -180,8 +188,11 @@ export function AccountSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingInvites = useMemo(() => invites.filter((invite) => !invite.used_at), [invites])
|
const pendingInvites = useMemo(() => invites.filter((invite) => !invite.used_at), [invites])
|
||||||
const ownerMember = useMemo(() => members.find((member) => member.account_role === 'owner') ?? null, [members])
|
const ownerMember = useMemo(
|
||||||
|
() => members.find((member) => member.account_role === 'owner') ?? null,
|
||||||
|
[members]
|
||||||
|
)
|
||||||
|
const memberCount = members.length || (isAccountOwner ? 1 : undefined)
|
||||||
|
|
||||||
const handleCopyDisplayCode = async () => {
|
const handleCopyDisplayCode = async () => {
|
||||||
if (!account?.display_code) return
|
if (!account?.display_code) return
|
||||||
@@ -276,40 +287,27 @@ export function AccountSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sub = subscription?.subscription
|
const sub = subscription?.subscription
|
||||||
|
const ownerName = ownerMember?.name ?? (user?.account_role === 'owner' ? user.name : null)
|
||||||
|
const inputClass = cn(
|
||||||
|
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary/40 focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="Account Settings" />
|
<PageMeta title="Account Settings" />
|
||||||
<div className="space-y-8">
|
<div className="mx-auto max-w-3xl space-y-10">
|
||||||
<div>
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
<h1 className="text-2xl font-bold font-heading text-foreground">Account Management</h1>
|
<header className="space-y-3">
|
||||||
<p className="mt-1 text-muted-foreground">
|
<div className="flex items-baseline gap-3">
|
||||||
Manage your account identity, billing, access, and workspace settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.25fr_0.95fr]">
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Account Identity</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Account Name</label>
|
|
||||||
{isEditingName ? (
|
{isEditingName ? (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedName}
|
value={editedName}
|
||||||
onChange={(e) => setEditedName(e.target.value)}
|
onChange={(e) => setEditedName(e.target.value)}
|
||||||
className={cn(
|
className={cn(inputClass, 'text-2xl font-bold font-heading py-1')}
|
||||||
'flex-1 rounded-md border border-border bg-card px-3 py-2',
|
|
||||||
'text-foreground placeholder:text-muted-foreground',
|
|
||||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
||||||
)}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') handleSaveName()
|
if (e.key === 'Enter') handleSaveName()
|
||||||
@@ -334,228 +332,152 @@ export function AccountSettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<>
|
||||||
<span className="text-sm text-foreground">{account?.name}</span>
|
<h1 className="text-2xl font-bold font-heading text-foreground">{account?.name}</h1>
|
||||||
{isAccountOwner && (
|
{isAccountOwner && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditingName(true)}
|
onClick={() => setIsEditingName(true)}
|
||||||
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
aria-label="Rename account"
|
||||||
>
|
>
|
||||||
Edit
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Display Code</label>
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<code className="rounded-md border border-border bg-card/50 px-3 py-2 font-mono text-sm text-foreground">
|
|
||||||
{account?.display_code}
|
|
||||||
</code>
|
|
||||||
<Button variant="secondary" size="icon-sm" onClick={handleCopyDisplayCode} title="Copy display code">
|
|
||||||
{copiedCode ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Share this code with teammates so they can join your account during registration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Account Owner</label>
|
|
||||||
<div className="mt-2 text-sm text-foreground">
|
|
||||||
{ownerMember ? ownerMember.name : user?.account_role === 'owner' ? user.email : 'Owner unavailable'}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{ownerMember?.email ?? 'The owner manages billing, branding, integrations, and membership changes.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Created</label>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{formatShortDate(account?.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground">Last updated</label>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{formatShortDate(account?.updated_at)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Crown className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Billing & Usage</h2>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Monitor plan status, renewal timing, and current account limits.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium',
|
|
||||||
plan === 'free' && 'bg-muted text-muted-foreground',
|
|
||||||
plan === 'pro' && 'bg-accent-dim text-accent-text',
|
|
||||||
plan === 'team' && 'bg-accent-dim text-accent-text'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Crown className="h-3.5 w-3.5" />
|
<Crown className="h-3.5 w-3.5" />
|
||||||
{plan.charAt(0).toUpperCase() + plan.slice(1)} Plan
|
{planLabel(plan)} plan
|
||||||
</span>
|
</span>
|
||||||
{sub && (
|
{sub && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium',
|
sub.status === 'active' && 'text-success',
|
||||||
sub.status === 'active' && 'bg-success-dim text-success',
|
sub.status === 'trialing' && 'text-info',
|
||||||
sub.status === 'trialing' && 'bg-info-dim text-info',
|
sub.status === 'past_due' && 'text-warning',
|
||||||
sub.status === 'past_due' && 'bg-warning-dim text-warning',
|
sub.status === 'canceled' && 'text-danger'
|
||||||
sub.status === 'canceled' && 'bg-danger-dim text-danger',
|
|
||||||
sub.status === 'orphaned' && 'bg-muted text-muted-foreground'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
|
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{ownerName && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>Owned by {ownerName}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{memberCount !== undefined && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>
|
||||||
|
{memberCount} {memberCount === 1 ? 'member' : 'members'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>Created {formatShortDate(account?.created_at)}</span>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Plan & Usage ───────────────────────────────────────────────── */}
|
||||||
|
<section className="space-y-4 border-t border-border pt-8">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<SectionLabel>Plan & usage</SectionLabel>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{sub?.current_period_end
|
||||||
|
? `Renews ${new Date(sub.current_period_end).toLocaleDateString()}`
|
||||||
|
: 'No renewal scheduled'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
{limits && usage ? (
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Renewal date</p>
|
<UsageRow label="Flows" current={usage.tree_count} max={limits.max_trees} />
|
||||||
<p className="mt-2 text-sm text-foreground">
|
<UsageRow
|
||||||
{sub?.current_period_end ? new Date(sub.current_period_end).toLocaleDateString() : 'Not scheduled'}
|
label="Sessions / month"
|
||||||
</p>
|
current={usage.session_count_this_month}
|
||||||
</div>
|
max={limits.max_sessions_per_month}
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
/>
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Branding access</p>
|
{(() => {
|
||||||
<p className="mt-2 text-sm text-foreground">
|
const seatCount = usage.user_count ?? (isAccountOwner ? members.length : null)
|
||||||
{limits?.custom_branding ? 'Included in your plan' : 'Upgrade required'}
|
return seatCount !== null ? (
|
||||||
</p>
|
<UsageRow label="Seats" current={seatCount} max={limits.max_users} />
|
||||||
</div>
|
) : null
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
{limits && usage && (
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
||||||
<UsageStat label="Flows" current={usage.tree_count} max={limits.max_trees} />
|
|
||||||
<UsageStat label="Sessions / month" current={usage.session_count_this_month} max={limits.max_sessions_per_month} />
|
|
||||||
<UsageStat label="Seats" current={usage.user_count} max={limits.max_users} />
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Plan limits unavailable.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plan === 'free' && (
|
{plan !== 'team' && (
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
<div className="flex flex-wrap justify-end gap-2 pt-2">
|
||||||
<CheckoutButton plan="pro" />
|
{plan === 'free' && <CheckoutButton plan="pro" />}
|
||||||
<CheckoutButton plan="team" />
|
<CheckoutButton plan="team" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plan === 'pro' && (
|
|
||||||
<div className="mt-5">
|
|
||||||
<CheckoutButton plan="team" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Access & Security</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Authentication</p>
|
|
||||||
<p className="mt-2 text-sm text-foreground">
|
|
||||||
{plan === 'team' ? 'Password auth available, SSO can be enabled.' : 'Password-based authentication is active.'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Use profile settings to update your personal details and sign-in information.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Single Sign-On</p>
|
|
||||||
<p className="mt-2 text-sm text-foreground">
|
|
||||||
{plan === 'team' ? 'Enterprise-ready setup available.' : 'Available on higher-tier account setups.'}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Contact support to configure SAML or OIDC for your organization.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
|
||||||
<div className="mt-5 rounded-xl border border-border bg-card/50 p-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-foreground">Need enterprise security controls?</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
We can help enable SSO and align account security for larger teams.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="mailto:support@resolutionflow.com?subject=SSO%20Setup%20Request"
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
|
|
||||||
'bg-muted border border-border text-foreground transition-colors hover:border-border-hover'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Contact Us
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="space-y-6">
|
{/* ── People ─────────────────────────────────────────────────────── */}
|
||||||
{!isAccountOwner && (
|
{isAccountOwner ? (
|
||||||
<div className="rounded-2xl border border-border bg-card/50 p-5">
|
<section className="space-y-5 border-t border-border pt-8">
|
||||||
<p className="text-sm text-muted-foreground">
|
<SectionLabel>People</SectionLabel>
|
||||||
Membership, invites, branding, and integrations are managed by the account owner. Contact your admin to make changes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
<form onSubmit={handleInvite} className="flex flex-wrap items-center gap-2">
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
|
<input
|
||||||
<div className="flex items-center gap-2">
|
type="email"
|
||||||
<Users className="h-5 w-5 text-muted-foreground" />
|
placeholder="teammate@company.com"
|
||||||
<h2 className="text-lg font-semibold text-foreground">Membership</h2>
|
value={inviteEmail}
|
||||||
</div>
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className={cn(inputClass, 'flex-1 min-w-[14rem]')}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={inviteRole}
|
||||||
|
onChange={(e) => setInviteRole(e.target.value)}
|
||||||
|
aria-label="Invite role"
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="engineer">Engineer</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
<Button type="submit" disabled={!inviteEmail.trim()} loading={isInviting}>
|
||||||
|
{isInviting ? 'Sending…' : 'Invite'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{members.length === 0 ? (
|
{(members.length > 0 || pendingInvites.length > 0) && (
|
||||||
<p className="mt-4 text-sm text-muted-foreground">No team members yet. Use the invite form below to add your first teammate.</p>
|
<ul className="divide-y divide-border">
|
||||||
) : (
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<div key={member.id} className="rounded-xl border border-border bg-card/50 p-4">
|
<li key={`member-${member.id}`} className="flex items-center gap-3 py-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="min-w-0 flex-1">
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-foreground">{member.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{member.email}</p>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
Last login: {formatShortDate(member.last_login)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
{!member.is_active && (
|
{!member.is_active && (
|
||||||
<span className="rounded-full bg-danger-dim px-2 py-0.5 text-xs text-danger">
|
<span className="rounded-full bg-danger-dim px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-danger">
|
||||||
Inactive
|
Inactive
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{member.email}
|
||||||
|
{member.last_login && (
|
||||||
|
<span> · Last seen {formatShortDate(member.last_login)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{member.account_role === 'owner' ? (
|
{member.account_role === 'owner' ? (
|
||||||
<span className="rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-accent-text">
|
<span className="rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-accent-text">
|
||||||
owner
|
Owner
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
@@ -563,10 +485,15 @@ export function AccountSettingsPage() {
|
|||||||
aria-label={`Role for ${member.name}`}
|
aria-label={`Role for ${member.name}`}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
try {
|
try {
|
||||||
const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
|
const updated = await accountsApi.updateMemberRole(
|
||||||
|
member.id,
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
setMembers((current) =>
|
setMembers((current) =>
|
||||||
current.map((entry) =>
|
current.map((entry) =>
|
||||||
entry.id === member.id ? { ...entry, account_role: updated.account_role } : entry
|
entry.id === member.id
|
||||||
|
? { ...entry, account_role: updated.account_role }
|
||||||
|
: entry
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
toast.success(`Role updated to ${updated.account_role}`)
|
toast.success(`Role updated to ${updated.account_role}`)
|
||||||
@@ -576,92 +503,51 @@ export function AccountSettingsPage() {
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md border border-border bg-card px-2 py-0.5 text-xs',
|
'rounded-md border border-border bg-card px-2 py-0.5 text-xs',
|
||||||
'text-foreground focus:border-primary focus:outline-hidden'
|
'text-foreground focus:border-primary/40 focus:outline-hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<option value="engineer">engineer</option>
|
<option value="engineer">Engineer</option>
|
||||||
<option value="viewer">viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
{member.account_role !== 'owner' && (
|
{member.account_role !== 'owner' && (
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
onConfirm={() => handleRemoveMember(member.id)}
|
onConfirm={() => handleRemoveMember(member.id)}
|
||||||
confirmLabel="Remove?"
|
confirmLabel="Remove?"
|
||||||
className="p-1 text-muted-foreground hover:text-danger"
|
className="rounded p-1 text-muted-foreground hover:text-danger"
|
||||||
confirmClassName="rounded px-1.5 py-0.5 text-xs font-medium text-danger bg-danger-dim"
|
confirmClassName="rounded px-1.5 py-0.5 text-xs font-medium text-danger bg-danger-dim"
|
||||||
aria-label={`Remove ${member.name}`}
|
aria-label={`Remove ${member.name}`}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Invites</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleInvite} className="mt-4 space-y-3">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email address"
|
|
||||||
value={inviteEmail}
|
|
||||||
onChange={(e) => setInviteEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
className={cn(
|
|
||||||
'w-full rounded-md border border-border bg-card px-3 py-2',
|
|
||||||
'text-foreground placeholder:text-muted-foreground',
|
|
||||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<select
|
|
||||||
value={inviteRole}
|
|
||||||
onChange={(e) => setInviteRole(e.target.value)}
|
|
||||||
className={cn(
|
|
||||||
'min-w-[140px] rounded-md border border-border bg-card px-3 py-2',
|
|
||||||
'text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<option value="engineer">Engineer</option>
|
|
||||||
<option value="viewer">Viewer</option>
|
|
||||||
</select>
|
|
||||||
<Button type="submit" disabled={!inviteEmail.trim()} loading={isInviting} className="flex-1">
|
|
||||||
{isInviting ? 'Sending...' : 'Send Invite'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{pendingInvites.length > 0 ? (
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{pendingInvites.map((invite) => (
|
{pendingInvites.map((invite) => (
|
||||||
<div key={invite.id} className="rounded-xl border border-border bg-card/50 p-4">
|
<li key={`invite-${invite.id}`} className="flex items-center gap-3 py-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<Mail className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-foreground">{invite.email}</p>
|
<div className="text-sm font-medium text-foreground truncate">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
{invite.email}
|
||||||
{invite.expires_at
|
|
||||||
? `Expires ${new Date(invite.expires_at).toLocaleDateString()}`
|
|
||||||
: 'No expiration'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-xs text-muted-foreground">
|
||||||
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
|
Pending
|
||||||
|
{invite.expires_at && (
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
· Expires {new Date(invite.expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
{invite.role}
|
{invite.role}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleResendInvite(invite.id)}
|
onClick={() => handleResendInvite(invite.id)}
|
||||||
disabled={resendingId === invite.id}
|
disabled={resendingId === invite.id}
|
||||||
className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
className="rounded p-1 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||||
aria-label={`Resend invite to ${invite.email}`}
|
aria-label={`Resend invite to ${invite.email}`}
|
||||||
>
|
>
|
||||||
{resendingId === invite.id ? (
|
{resendingId === invite.id ? (
|
||||||
@@ -670,77 +556,122 @@ export function AccountSettingsPage() {
|
|||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
) : (
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground">No pending invites. Use the form above to invite teammates by email.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6">
|
{account?.display_code && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
<span>Share to invite during signup:</span>
|
||||||
<h2 className="text-lg font-semibold text-foreground">Preferences</h2>
|
<code className="rounded border border-border bg-code px-2 py-0.5 font-mono text-sm text-foreground">
|
||||||
</div>
|
{account.display_code}
|
||||||
|
</code>
|
||||||
<div className="mt-4">
|
<button
|
||||||
<label htmlFor="export-format" className="block text-sm font-medium text-foreground">
|
onClick={handleCopyDisplayCode}
|
||||||
Default Export Format
|
className="rounded p-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||||
</label>
|
aria-label="Copy display code"
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This format will be pre-selected when exporting sessions.
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
id="export-format"
|
|
||||||
value={defaultExportFormat}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDefaultExportFormat(e.target.value as 'markdown' | 'text' | 'html')
|
|
||||||
toast.success('Preference saved')
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'mt-2 block w-full rounded-md border border-border bg-card px-3 py-2',
|
|
||||||
'text-sm text-foreground',
|
|
||||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<option value="markdown">Markdown (.md)</option>
|
{copiedCode ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
<option value="text">Plain Text (.txt)</option>
|
</button>
|
||||||
<option value="html">HTML (.html)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="border-t border-border pt-8">
|
||||||
|
<SectionLabel>People</SectionLabel>
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
Membership and invites are managed by the account owner
|
||||||
|
{ownerName && <span> ({ownerName})</span>}. Contact your admin to make changes.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Settings ───────────────────────────────────────────────────── */}
|
||||||
|
<section className="space-y-4 border-t border-border pt-8">
|
||||||
|
<SectionLabel>Settings</SectionLabel>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/profile"
|
||||||
|
icon={<UserCog className="h-4 w-4" />}
|
||||||
|
title="Profile"
|
||||||
|
description="Your name, email, and personal preferences"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-danger/20 p-5 sm:p-6">
|
{isAccountOwner && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-1 border-t border-border pt-4">
|
||||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
<SettingsRow
|
||||||
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
|
to="/account/branding"
|
||||||
|
icon={<Palette className="h-4 w-4" />}
|
||||||
|
title="Branding"
|
||||||
|
description="Logo, accent color, and company name"
|
||||||
|
status={
|
||||||
|
limits?.custom_branding
|
||||||
|
? { label: 'Included', tone: 'positive' }
|
||||||
|
: { label: 'Plan gated', tone: 'warning' }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/integrations"
|
||||||
|
icon={<Plug className="h-4 w-4" />}
|
||||||
|
title="Integrations"
|
||||||
|
description="Connect PSA tools and external systems"
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/chat-retention"
|
||||||
|
icon={<Clock className="h-4 w-4" />}
|
||||||
|
title="Chat retention"
|
||||||
|
description="Conversation retention and assistant data lifecycle"
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/categories"
|
||||||
|
icon={<FolderTree className="h-4 w-4" />}
|
||||||
|
title="Team categories"
|
||||||
|
description="Shared flow categories for your workspace"
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/target-lists"
|
||||||
|
icon={<Server className="h-4 w-4" />}
|
||||||
|
title="Target lists"
|
||||||
|
description="Saved server and device lists for the team"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="pt-2">
|
||||||
|
<Link
|
||||||
|
to="/feedback"
|
||||||
|
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<MessageSquareText className="h-3.5 w-3.5" />
|
||||||
|
Need help? Send feedback or report a bug
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Account actions (transfer / delete / leave) ────────────────── */}
|
||||||
|
<section className="border-t border-border pt-8 space-y-3">
|
||||||
{isAccountOwner ? (
|
{isAccountOwner ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/40 p-4">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">Transfer Ownership</p>
|
<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>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Move ownership of this account to another member.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="secondary" size="sm" onClick={() => setShowTransferModal(true)}>
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowTransferModal(true)}
|
|
||||||
className="border-warning/30 text-warning hover:bg-warning-dim"
|
|
||||||
>
|
|
||||||
Transfer
|
Transfer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/40 p-4">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">Delete Account</p>
|
<p className="text-sm font-medium text-danger">Delete account</p>
|
||||||
<p className="text-xs text-muted-foreground">Permanently delete your account and all data.</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Permanently delete the account and all data. Cannot be undone.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="destructive" size="sm" onClick={() => setShowDeleteModal(true)}>
|
<Button variant="destructive" size="sm" onClick={() => setShowDeleteModal(true)}>
|
||||||
Delete
|
Delete
|
||||||
@@ -748,90 +679,18 @@ export function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/40 p-4">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">Leave Account</p>
|
<p className="text-sm font-medium text-danger">Leave account</p>
|
||||||
<p className="text-xs text-muted-foreground">Leave this account and create a personal one.</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave this account and create a personal one.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="destructive" size="sm" onClick={() => setShowLeaveModal(true)}>
|
<Button variant="destructive" size="sm" onClick={() => setShowLeaveModal(true)}>
|
||||||
Leave
|
Leave
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Settings Areas</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Common account management actions, organized the way most SaaS teams expect to find them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<SettingsLinkCard
|
|
||||||
to="/account/profile"
|
|
||||||
icon={<UserCog className="h-5 w-5" />}
|
|
||||||
title="Profile Settings"
|
|
||||||
description="Update your name, email, and personal details."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
|
||||||
<SettingsLinkCard
|
|
||||||
to="/account/branding"
|
|
||||||
icon={<Palette className="h-5 w-5" />}
|
|
||||||
title="Branding"
|
|
||||||
description="Customize logo, accent color, and company name."
|
|
||||||
badge={limits?.custom_branding ? 'Included' : 'Plan gated'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
|
||||||
<SettingsLinkCard
|
|
||||||
to="/account/integrations"
|
|
||||||
icon={<Plug className="h-5 w-5" />}
|
|
||||||
title="Integrations"
|
|
||||||
description="Connect PSA and other external systems for your team."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
|
||||||
<SettingsLinkCard
|
|
||||||
to="/account/chat-retention"
|
|
||||||
icon={<Clock className="h-5 w-5" />}
|
|
||||||
title="Chat Retention"
|
|
||||||
description="Control conversation retention and assistant data lifecycle."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
|
||||||
<SettingsLinkCard
|
|
||||||
to="/account/categories"
|
|
||||||
icon={<FolderTree className="h-5 w-5" />}
|
|
||||||
title="Team Categories"
|
|
||||||
description="Manage shared flow categories for your workspace."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAccountOwner && (
|
|
||||||
<SettingsLinkCard
|
|
||||||
to="/account/target-lists"
|
|
||||||
icon={<Server className="h-5 w-5" />}
|
|
||||||
title="Target Lists"
|
|
||||||
description="Maintain saved server and device lists for the team."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsLinkCard
|
|
||||||
to="/feedback"
|
|
||||||
icon={<MessageSquareText className="h-5 w-5" />}
|
|
||||||
title="Support & Feedback"
|
|
||||||
description="Report bugs, request features, or share product feedback."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{showTransferModal && (
|
{showTransferModal && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react'
|
import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import type { UserUpdate } from '@/types'
|
import type { UserUpdate } from '@/types'
|
||||||
@@ -16,6 +17,7 @@ const inputClass = cn(
|
|||||||
export function ProfileSettingsPage() {
|
export function ProfileSettingsPage() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||||
|
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
|
||||||
|
|
||||||
const [name, setName] = useState(user?.name ?? '')
|
const [name, setName] = useState(user?.name ?? '')
|
||||||
const [email, setEmail] = useState(user?.email ?? '')
|
const [email, setEmail] = useState(user?.email ?? '')
|
||||||
@@ -120,6 +122,27 @@ export function ProfileSettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Default export format — saved on change, not via Save Changes */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="profile-export-format" className="block text-sm font-medium text-foreground">
|
||||||
|
Default export format
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">Pre-selected when exporting sessions.</p>
|
||||||
|
<select
|
||||||
|
id="profile-export-format"
|
||||||
|
value={defaultExportFormat}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDefaultExportFormat(e.target.value as 'markdown' | 'text' | 'html')
|
||||||
|
toast.success('Preference saved')
|
||||||
|
}}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="markdown">Markdown (.md)</option>
|
||||||
|
<option value="text">Plain text (.txt)</option>
|
||||||
|
<option value="html">HTML (.html)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 text-sm text-rose-500">
|
<div className="flex items-center gap-2 text-sm text-rose-500">
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
|||||||
17
skills-lock.json
Normal file
17
skills-lock.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"documentation-writer": {
|
||||||
|
"source": "github/awesome-copilot",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/documentation-writer/SKILL.md",
|
||||||
|
"computedHash": "ee53d65b163cd7eb953a930c95841cfe398cc2c0bd24c06508bbaa07c432be35"
|
||||||
|
},
|
||||||
|
"impeccable": {
|
||||||
|
"source": "pbakaus/impeccable",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": ".agents/skills/impeccable/SKILL.md",
|
||||||
|
"computedHash": "de38608ceb9573c3142306babd9057a240791c4e4d6647f874a4650c996cb37e"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user