Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).
Backend additions:
- accounts/me/security GET response gains active_users: list of
{user_id, name, email, last_login_at} for users in this account
with at least one un-revoked refresh token. Joined query on
refresh_tokens + users, distinct, ordered by last_login desc.
Drives the Active Sessions section.
Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
auth store, returns warning ('none'|'soon'|'now') + reason
('idle'|'absolute') so consumers can pick the right UX for the
closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
button hits authApi.refresh() and updates the store on success.
Absolute case: info-cyan tone, [Sign in now] link to /login (no
recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
the two bulk-revoke scopes. Title, body, and confirm-label vary by
scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
Header (Shield icon), intro, Policy card with Strict/Standard/Custom
radios + always-visible-disabled Custom inputs (idle/absolute
minutes) with inline validation, Save button + emerald success ping,
info note about 'applies at next login'. Active sessions card with
count-aware copy, list of {name, email, last-login-ago} rows
(caller tagged '(you)'), two buttons — 'except me' hidden when
count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.
Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).
Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
7.4 KiB
TypeScript
192 lines
7.4 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
|
import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Wand2, BarChart3, Settings, LogOut, Shield, FileText } from 'lucide-react'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|
import { useBillingPoll } from '@/hooks/useBillingPoll'
|
|
import { BrandLogo } from '@/components/common/BrandLogo'
|
|
import { TopBar } from './TopBar'
|
|
import { Sidebar } from './Sidebar'
|
|
import { EmailVerificationBanner } from './EmailVerificationBanner'
|
|
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
|
|
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
|
|
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
|
|
import { SessionExpiryToast } from '@/components/common/SessionExpiryToast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export function AppLayout() {
|
|
// Poll /billing/state every 60s while authenticated. Hook no-ops when logged out.
|
|
useBillingPoll()
|
|
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
const { user, logout } = useAuthStore()
|
|
const { effectiveRole } = usePermissions()
|
|
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
|
|
|
// Close mobile menu on route change
|
|
const [prevPath, setPrevPath] = useState(location.pathname)
|
|
if (prevPath !== location.pathname) {
|
|
setPrevPath(location.pathname)
|
|
if (mobileMenuOpen) setMobileMenuOpen(false)
|
|
}
|
|
|
|
// Close on Escape
|
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setMobileMenuOpen(false)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (mobileMenuOpen) {
|
|
document.addEventListener('keydown', handleKeyDown)
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown)
|
|
document.body.style.overflow = ''
|
|
}
|
|
}, [mobileMenuOpen, handleKeyDown])
|
|
|
|
const handleLogout = async () => {
|
|
setMobileMenuOpen(false)
|
|
await logout()
|
|
navigate('/login')
|
|
}
|
|
|
|
const mobileNavItems = [
|
|
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
|
|
{ path: '/sessions', label: 'Session History', icon: Clock },
|
|
{ path: '/escalations', label: 'Escalations', icon: AlertTriangle },
|
|
{ path: '/trees', label: 'Guided Flows', icon: GitBranch },
|
|
{ path: '/scripts', label: 'Scripts', icon: FileText },
|
|
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
|
|
{ path: '/analytics', label: 'Analytics', icon: BarChart3 },
|
|
{ path: '/account', label: 'Account', icon: Settings },
|
|
]
|
|
|
|
return (
|
|
<>
|
|
<SessionExpiryToast />
|
|
<div
|
|
className={cn('app-shell relative z-1', sidebarPinned && 'app-shell--pinned')}
|
|
data-testid="app-shell"
|
|
>
|
|
{/* Top Bar - spans full width */}
|
|
<TopBar />
|
|
|
|
{/* Sidebar - desktop only, must fill grid row */}
|
|
<div className="hidden md:flex md:flex-col md:min-h-0 md:h-full">
|
|
<Sidebar />
|
|
</div>
|
|
|
|
{/* Mobile hamburger - overlaid on topbar */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setMobileMenuOpen(true)}
|
|
className="fixed left-4 top-3.5 z-50 rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors md:hidden"
|
|
aria-label="Open menu"
|
|
>
|
|
<Menu size={20} />
|
|
</button>
|
|
|
|
{/* Mobile Nav Drawer */}
|
|
{mobileMenuOpen && (
|
|
<div className="fixed inset-0 z-50 md:hidden">
|
|
<div
|
|
className="absolute inset-0 bg-black/80 backdrop-blur-xs animate-fade-in"
|
|
onClick={() => setMobileMenuOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
<nav
|
|
className="absolute inset-y-0 left-0 w-72 shadow-2xl animate-slide-in-left"
|
|
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }}
|
|
>
|
|
<div className="flex h-14 items-center justify-between px-4" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
|
<Link to="/" className="flex items-center gap-2.5">
|
|
<BrandLogo size="sm" />
|
|
<span className="text-sm font-heading font-bold text-text-heading">ResolutionFlow</span>
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMobileMenuOpen(false)}
|
|
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground"
|
|
aria-label="Close menu"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col p-3">
|
|
{/* User info */}
|
|
<div className="mb-3 pb-3 px-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
|
<p className="text-sm font-medium text-foreground">{user?.name || user?.email}</p>
|
|
{effectiveRole && effectiveRole !== 'engineer' && (
|
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Shield size={10} />
|
|
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Nav items */}
|
|
<div className="space-y-0.5">
|
|
{mobileNavItems.map((item) => {
|
|
const Icon = item.icon
|
|
const isActive = item.path === '/'
|
|
? location.pathname === '/'
|
|
: location.pathname.startsWith(item.path)
|
|
return (
|
|
<Link
|
|
key={item.path + item.label}
|
|
to={item.path}
|
|
className={cn(
|
|
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
|
isActive
|
|
? 'bg-accent-dim text-foreground'
|
|
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
|
)}
|
|
>
|
|
<Icon size={18} />
|
|
{item.label}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
|
<button
|
|
type="button"
|
|
onClick={handleLogout}
|
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-input hover:text-foreground transition-colors"
|
|
>
|
|
<LogOut size={18} />
|
|
Logout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content */}
|
|
<main className="main-content flex flex-col overflow-hidden min-h-0">
|
|
<EmailVerificationBanner />
|
|
<EmailVerificationGate>
|
|
<ViewTransitionOutlet />
|
|
</EmailVerificationGate>
|
|
</main>
|
|
</div>
|
|
|
|
{/* Beta Feedback Widget — persistent on all authenticated pages */}
|
|
<FeedbackWidget />
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default AppLayout
|