fix(auth): token refresh lifecycle fixes + auth mode detection improvements

- authDebug.ts: rewrite simulateExpiry to remove access token and trigger
  real iam.getAccessToken() refresh path (reliable expiry simulation)
- ProtectedRoute.tsx: improved auth state guards
- auth_mode.py: expose provider_name in GET /api/v1/auth-mode response
- api/client.ts: add provider_name to ApiAuthMode type; 401 handler calls
  iam.refreshToken() directly instead of iam.init() (which is a no-op)
- Layout.tsx / useAuthMode / useAuthModeContext: wire auth mode context
- Profile.tsx / useProfile: use server-side author filtering
- webapp: upgrade @armco/iam-client to 0.1.5 (getAccessToken refresh fix,
  grant_types_supported server-side check)
This commit is contained in:
2026-05-04 03:13:48 +05:30
parent 05fa6d69d7
commit d7c7e44e34
11 changed files with 183 additions and 251 deletions

View File

@@ -103,6 +103,7 @@ _PUBLIC_PATH_PREFIXES: List[str] = [
"/api/v1/runtime/version",
"/api/v1/billing/public-config",
"/api/v1/billing/webhooks/paddle",
"/api/v1/legal-documents",
# NOTE: /api/v1/blog intentionally omitted — blog endpoints use
# get_optional_identity which handles unauthenticated callers,
# so tokens must be validated to give owners/admins draft visibility.

View File

@@ -29,6 +29,7 @@ class AuthModeResponse(BaseModel):
multi_user: bool
iam_enabled: bool
iam_issuer: Optional[str]
provider_name: Optional[str]
source: str
@@ -48,12 +49,13 @@ async def get_auth_mode(
# Priority 1: DB config (saved via UI / setup wizard)
iam_enabled = False
issuer: Optional[str] = None
provider_name: Optional[str] = None
source = "none"
try:
row = await db.fetchrow(
"""
SELECT config_encrypted
SELECT provider_name, config_encrypted
FROM installation_provider_config
WHERE category = 'auth' AND is_active = TRUE
LIMIT 1
@@ -71,6 +73,7 @@ async def get_auth_mode(
if db_issuer:
iam_enabled = True
issuer = db_issuer.rstrip("/")
provider_name = row.get("provider_name")
source = "db"
except Exception as exc:
log.warn("auth_mode_db_read_failed", {
@@ -107,5 +110,6 @@ async def get_auth_mode(
multi_user=iam_enabled,
iam_enabled=iam_enabled,
iam_issuer=issuer,
provider_name=provider_name,
source=source,
)

View File

@@ -124,6 +124,7 @@ export interface ApiAuthMode {
multi_user: boolean
iam_enabled: boolean
iam_issuer: string | null
provider_name?: string | null
}
export const authModeApi = {
@@ -2960,12 +2961,36 @@ export interface ApiTnCStatus {
accepted_at: string | null
}
export type ApiLegalDocumentType = 'terms' | 'privacy'
export interface ApiLegalDocumentSection {
section_slug: string
title: string
content_markdown: string
sort_order: number
}
export interface ApiLegalDocument {
document_type: ApiLegalDocumentType
title: string
summary: string
version: string
effective_date: string
source: string
sections: ApiLegalDocumentSection[]
}
export interface ApiTnCAccept {
accepted: boolean
version: string
accepted_at: string
}
export const legalDocumentsApi = {
get: (documentType: ApiLegalDocumentType) =>
get<ApiLegalDocument>(`/legal-documents/${documentType}`),
}
export const tncApi = {
status: () => get<ApiTnCStatus>('/tnc/status'),
accept: () => post<ApiTnCAccept>('/tnc/accept', {}),

View File

@@ -1,6 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './useAuth'
import { useTnC } from '@/hooks/useTnC'
import { useAuthModeContext } from '@/hooks/useAuthModeContext'
import { TnCModal } from '@/components/TnCModal'
interface Props {
@@ -9,9 +10,10 @@ interface Props {
export function ProtectedRoute({ children }: Props) {
const { isLoading, isAuthenticated, user, logout } = useAuth()
const { isMultiUser } = useAuthModeContext()
const location = useLocation()
const userSub = user?.sub ?? null
const { needsAcceptance, acceptTnC } = useTnC(
const { needsAcceptance, currentVersion, acceptTnC } = useTnC(
isAuthenticated ? userSub : null
)
@@ -30,8 +32,9 @@ export function ProtectedRoute({ children }: Props) {
return (
<>
{needsAcceptance && (
{isMultiUser && needsAcceptance && (
<TnCModal
currentVersion={currentVersion}
onAccept={acceptTnC}
onDecline={() => logout()}
/>

View File

@@ -5,10 +5,10 @@
* Use from browser DevTools to simulate token expiry and trace the refresh lifecycle.
*
* Usage:
* window.__NEBULA_AUTH_DEBUG__.status() — show current token state
* window.__NEBULA_AUTH_DEBUG__.simulateExpiry() — expire access token, keep refresh token
* window.__NEBULA_AUTH_DEBUG__.simulateFullExpiry() — expire both tokens
* window.__NEBULA_AUTH_DEBUG__.forceRefresh() — manually trigger a token refresh now
* window.__NEBULA_AUTH_DEBUG__.status() — show current token state
* window.__NEBULA_AUTH_DEBUG__.simulateExpiry() — remove access token, keep refresh token, immediately trigger refresh
* window.__NEBULA_AUTH_DEBUG__.simulateFullExpiry() — remove both tokens (next API call → /login)
* window.__NEBULA_AUTH_DEBUG__.forceRefresh() — call iam.refreshToken() directly now
*/
const IAM_PREFIX = 'stuffle_iam_'
@@ -29,19 +29,6 @@ function _decodeJwtPayload(token: string): Record<string, unknown> | null {
}
}
function _encodeJwtWithExpiry(token: string, newExp: number): string {
const parts = token.split('.')
if (parts.length !== 3) return token
const payload = _decodeJwtPayload(token)
if (!payload) return token
payload.exp = newExp
const newPayload = btoa(JSON.stringify(payload))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `${parts[0]}.${newPayload}.${parts[2]}` // signature is invalid but client doesn't verify
}
export interface AuthDebugStatus {
hasAccessToken: boolean
hasRefreshToken: boolean
@@ -94,59 +81,79 @@ function status(): AuthDebugStatus {
return result
}
function simulateExpiry(): void {
const at = localStorage.getItem(KEYS.ACCESS_TOKEN)
if (!at) {
console.warn('[auth-debug] No access token in storage')
/**
* Simulates the exact scenario that causes hourly logouts:
* - Removes the access token from storage (as if it expired)
* - Keeps the refresh token intact
* - Immediately calls iam.getAccessToken() which should trigger refreshToken()
* Watch console for [IAMClient] logs to see the full flow.
*/
async function simulateExpiry(): Promise<void> {
const rt = localStorage.getItem(KEYS.REFRESH_TOKEN)
if (!rt) {
console.warn('[auth-debug] No refresh token — cannot simulate expiry without one')
return
}
const pastExp = Math.floor(Date.now() / 1000) - 3600 // 1hr ago
const expired = _encodeJwtWithExpiry(at, pastExp)
localStorage.setItem(KEYS.ACCESS_TOKEN, expired)
// Also move EXPIRES_AT to the past
localStorage.setItem(KEYS.EXPIRES_AT, (Date.now() - 3600_000).toString())
console.warn(
'[auth-debug] Access token exp set to 1hr ago. ' +
'Refresh token is INTACT. Next API call or page interaction should trigger a silent refresh. ' +
'Watch for [IAMClient] logs.',
)
console.group('[auth-debug] simulateExpiry — removing access token, keeping refresh token')
console.debug('[auth-debug] Before:', {
hasAccessToken: !!localStorage.getItem(KEYS.ACCESS_TOKEN),
hasRefreshToken: !!rt,
})
// Remove access token and set EXPIRES_AT to past — this is what really happens when token expires
localStorage.removeItem(KEYS.ACCESS_TOKEN)
localStorage.setItem(KEYS.EXPIRES_AT, (Date.now() - 1000).toString())
console.debug('[auth-debug] Access token removed. Calling iam.getAccessToken() to trigger refresh...')
const { iam } = await import('./iam')
const newToken = await iam.getAccessToken()
if (newToken) {
console.log('[auth-debug] ✅ Refresh SUCCEEDED — new access token received')
console.debug('[auth-debug] iam.isAuthenticated() =', iam.isAuthenticated())
} else {
console.error('[auth-debug] ❌ Refresh FAILED — no new token returned')
console.error('[auth-debug] Check [IAMClient] logs above for the failure reason')
console.error('[auth-debug] If this happens hourly, the refresh token may be rejected by the IAM server')
}
console.groupEnd()
}
/**
* Simulates total session expiry — both tokens gone.
* The next API call should result in a /login redirect.
*/
function simulateFullExpiry(): void {
const at = localStorage.getItem(KEYS.ACCESS_TOKEN)
const rt = localStorage.getItem(KEYS.REFRESH_TOKEN)
if (!at && !rt) {
console.warn('[auth-debug] No tokens in storage')
console.warn('[auth-debug] No tokens in storage to clear')
return
}
if (at) {
const pastExp = Math.floor(Date.now() / 1000) - 3600
localStorage.setItem(KEYS.ACCESS_TOKEN, _encodeJwtWithExpiry(at, pastExp))
}
// Remove refresh token entirely to simulate fully expired session
localStorage.removeItem(KEYS.ACCESS_TOKEN)
localStorage.removeItem(KEYS.REFRESH_TOKEN)
localStorage.setItem(KEYS.EXPIRES_AT, (Date.now() - 3600_000).toString())
localStorage.setItem(KEYS.EXPIRES_AT, (Date.now() - 1000).toString())
console.warn(
'[auth-debug] Both access token expired AND refresh token removed. ' +
'Next API call should result in /login redirect.',
'[auth-debug] Both tokens removed. Next API call or iam.init() will return unauthenticated → /login redirect.',
)
}
async function forceRefresh(): Promise<void> {
// Dynamic import to avoid circular deps — iam is the singleton proxy
const { iam } = await import('./iam')
console.debug('[auth-debug] Calling iam.refreshToken() directly...')
const result = await iam.refreshToken()
if (result) {
console.log('[auth-debug] Refresh SUCCEEDED. New token expires_in:', result.expires_in, 's')
console.log('[auth-debug] Refresh SUCCEEDED. expires_in =', result.expires_in, 's')
} else {
console.error('[auth-debug] Refresh FAILED — check [IAMClient] logs above for the error')
console.error('[auth-debug] Refresh FAILED — check [IAMClient] logs above')
}
}
export interface AuthDebugAPI {
status: () => AuthDebugStatus
simulateExpiry: () => void
simulateExpiry: () => Promise<void>
simulateFullExpiry: () => void
forceRefresh: () => Promise<void>
}
@@ -160,9 +167,9 @@ export function installAuthDebug(): void {
console.debug(
'[auth-debug] window.__NEBULA_AUTH_DEBUG__ installed.\n' +
' .status() — show token state\n' +
' .simulateExpiry() — expire access token (keep refresh token)\n' +
' .simulateFullExpiry()— expire both tokens\n' +
' .forceRefresh() — trigger refresh now',
' .status() — show current token state\n' +
' .simulateExpiry() — remove access token + trigger getAccessToken() → should refresh\n' +
' .simulateFullExpiry()— remove both tokens → next API call redirects to /login\n' +
' .forceRefresh() — call iam.refreshToken() directly now',
)
}

View File

@@ -15,6 +15,7 @@ import { ShellLayout } from './ShellLayout'
import { useBackgroundEvents } from '@/hooks/useBackgroundEvents'
import { useAuth } from '@/auth/useAuth'
import { CommandPalette } from '@/components/CommandPalette'
import { ShortlistBar } from './ShortlistBar'
function BackgroundEventsMount() {
const { isAuthenticated } = useAuth()
@@ -48,6 +49,7 @@ export function Layout() {
<div className="ne-Layout__body">
<ShellLayout />
<HelpPanel />
<ShortlistBar />
</div>
<BrowserCLI />
<CommandPalette />

View File

@@ -15,6 +15,7 @@ export interface AuthMode {
isMultiUser: boolean
iamEnabled: boolean
iamIssuer: string | null
providerName: string | null
isLoading: boolean
}
@@ -22,6 +23,7 @@ const _fallback: ApiAuthMode = {
multi_user: false,
iam_enabled: false,
iam_issuer: null,
provider_name: null,
}
export function useAuthMode(): AuthMode {
@@ -38,6 +40,7 @@ export function useAuthMode(): AuthMode {
isMultiUser: resolved.multi_user,
iamEnabled: resolved.iam_enabled,
iamIssuer: resolved.iam_issuer,
providerName: resolved.provider_name ?? null,
isLoading,
}
}

View File

@@ -5,6 +5,7 @@ const _default: AuthMode = {
isMultiUser: false,
iamEnabled: false,
iamIssuer: null,
providerName: null,
isLoading: false,
}

View File

@@ -4,6 +4,9 @@ export interface UserProfile {
name: string
avatar: string | null
userId: string | null
mobile: string | null
countryCode: string
address: string | null
}
const STORAGE_KEY = 'nebula_user_profile'
@@ -15,7 +18,14 @@ function load(): UserProfile {
} catch {
// ignore parse errors
}
return { name: 'Anonymous', avatar: null, userId: null }
return {
name: 'Anonymous',
avatar: null,
userId: null,
mobile: null,
countryCode: '+91',
address: null,
}
}
function save(profile: UserProfile): void {

View File

@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from '@/theme'
import { ShortlistProvider } from '@/context/ShortlistContext'
import './index.css'
import App from './App'
@@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<App />
<ShortlistProvider>
<App />
</ShortlistProvider>
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,

View File

@@ -1,16 +1,12 @@
import { useState, useRef, type KeyboardEvent } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
User, HelpCircle, FileText, CheckCircle2, MessageSquare,
Bot, ListTodo, Shield, Puzzle, Plug, Calendar, MessageCircle,
GitBranch, ToggleLeft, ToggleRight, ArrowRight,
ShoppingBag, Layers,
MessageCircle, ArrowRight, ShoppingBag, Layers,
} from 'lucide-react'
import {
qnaApi, blogApi, agentsApi, tasksApi, policiesApi,
pluginsApi, integrationsApi, workflowsApi,
ordersApi, constellationsApi,
qnaApi, blogApi, ordersApi, constellationsApi, meApi,
} from '@/api/client'
import { useProfile } from '@/hooks/useProfile'
import { useAuthModeContext } from '@/hooks/useAuthModeContext'
@@ -108,45 +104,25 @@ function Dot({ color, icon }: { color: string; icon: React.ReactNode }) {
export default function Profile() {
const navigate = useNavigate()
const { profile, setProfile } = useProfile()
const { isMultiUser } = useAuthModeContext()
const { isMultiUser, providerName } = useAuthModeContext()
const { user } = useAuth()
const [editingName, setEditingName] = useState(false)
const [draftName, setDraftName] = useState(profile.name)
const inputRef = useRef<HTMLInputElement>(null)
const { data: me } = useQuery({ queryKey: ['profile-me'], queryFn: () => meApi.get(), retry: false })
// ── Data fetches ────────────────────────────────────────────────────────────
const authorId = isMultiUser && user?.sub ? user.sub : undefined
const { data: qnaData, isLoading: qnaLoading } = useQuery({ queryKey: ['profile-qna', authorId ?? profile.name], queryFn: () => qnaApi.list(authorId ? { page_size: 100, author_id: authorId } : { page_size: 100, author_name: profile.name }) })
const { data: blogData, isLoading: blogLoading } = useQuery({ queryKey: ['profile-blog', authorId ?? profile.name], queryFn: () => blogApi.list(authorId ? { page_size: 100, author_id: authorId } : { page_size: 100, author_name: profile.name }) })
const { data: agentData, isLoading: agentLoading } = useQuery({ queryKey: ['profile-agents'], queryFn: () => agentsApi.list({ page_size: 100 }) })
const { data: taskData, isLoading: taskLoading } = useQuery({ queryKey: ['profile-tasks'], queryFn: () => tasksApi.list({ page_size: 100 }) })
const { data: policyData, isLoading: policyLoading } = useQuery({ queryKey: ['profile-policies'], queryFn: () => policiesApi.list({ page_size: 100 }) })
const { data: pluginData, isLoading: pluginLoading } = useQuery({ queryKey: ['profile-plugins'], queryFn: () => pluginsApi.list({ page_size: 100 }) })
const { data: integData, isLoading: integLoading } = useQuery({ queryKey: ['profile-integ'], queryFn: () => integrationsApi.instances() })
const { data: jobData, isLoading: jobLoading } = useQuery({ queryKey: ['profile-jobs'], queryFn: () => integrationsApi.jobs() })
const { data: wfData, isLoading: wfLoading } = useQuery({ queryKey: ['profile-workflows'], queryFn: () => workflowsApi.list({ page_size: 100 }) })
const { data: orderData, isLoading: orderLoading } = useQuery({ queryKey: ['profile-orders'], queryFn: () => ordersApi.list({ created_by: profile.name, page_size: 100 }) })
const { data: cstData, isLoading: cstLoading } = useQuery({ queryKey: ['profile-constellations'], queryFn: () => constellationsApi.list({ created_by: profile.name, include_public: false, page_size: 100 }) })
const myQuestions = qnaData?.items ?? []
const myPosts = blogData?.items ?? []
const myAgents = agentData?.items ?? []
const myTasks = taskData?.items ?? []
const myPolicies = policyData?.items ?? []
const myPlugins = pluginData?.items ?? []
const myIntegrations = integData ?? []
const myJobs = jobData ?? []
const myWorkflows = wfData?.items ?? []
const myOrders = orderData?.items ?? []
const myConstellations = cstData?.items ?? []
const queryClient = useQueryClient()
const togglePolicy = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
policiesApi.update(id, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profile-policies'] }),
})
function commitName() {
const trimmed = draftName.trim()
if (trimmed) setProfile({ name: trimmed })
@@ -158,24 +134,29 @@ export default function Profile() {
}
const initials = profile.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
const loginLabel = me?.auth_method === 'dev_bypass'
? 'Stuffle (Dev Bypass)'
: me?.auth_method === 'dev_sim'
? 'Stuffle (Simulator)'
: isMultiUser
? (providerName ?? 'Stuffle / IAM')
: 'Stuffle (Single-user)'
const resolvedEmail = me?.email ?? user?.email ?? '—'
const resolvedName = me?.name ?? profile.name
const answerCount = myQuestions.filter(q => q.answer_count > 0).length
const commentActivityCount = myQuestions.length + myPosts.filter(p => p.comment_count > 0).length
// ── Status colour helpers ────────────────────────────────────────────────────
const qnaV = (s: string) => ({ open: 'amber', answered: 'green', closed: 'muted' } as Record<string, 'green' | 'amber' | 'muted'>)[s] ?? 'muted'
const blogV = (s: string) => ({ published: 'green', draft: 'amber', archived: 'muted' } as Record<string, 'green' | 'amber' | 'muted'>)[s] ?? 'muted'
const agentV = (s: string) => ({ running: 'green', idle: 'muted', error: 'red', paused: 'amber' } as Record<string, 'green' | 'amber' | 'muted' | 'red'>)[s] ?? 'muted'
const taskV = (s: string) => ({ completed: 'green', running: 'cyan', failed: 'red', pending: 'amber', cancelled: 'muted' } as Record<string, 'green' | 'amber' | 'muted' | 'red' | 'cyan'>)[s] ?? 'muted'
const integV = (s: string) => ({ connected: 'green', error: 'red', connecting: 'amber', disconnected: 'muted' } as Record<string, 'green' | 'amber' | 'muted' | 'red'>)[s] ?? 'muted'
const wfV = (s: string) => ({ active: 'green', draft: 'amber', archived: 'muted' } as Record<string, 'green' | 'amber' | 'muted'>)[s] ?? 'muted'
const totalEntities =
myAgents.length + myTasks.length + myWorkflows.length + myJobs.length +
myPolicies.length + myPlugins.length + myIntegrations.length +
myQuestions.length + myPosts.length +
myQuestions.length + myPosts.length + answerCount +
myOrders.length + myConstellations.length
return (
<PageShell page="profile">
<PageHeader icon={<User size={20} />} title="Profile" subtitle="Your activity on NebulaOS" />
<PageHeader icon={<User size={20} />} title="Profile" subtitle="Account details and personal activity" />
<div className="ne-Profile">
@@ -204,7 +185,7 @@ export default function Profile() {
/>
) : (
<>
<span className="ne-Profile__name">{profile.name}</span>
<span className="ne-Profile__name">{resolvedName}</span>
<button className="ne-Profile__name-edit" onClick={() => { setDraftName(profile.name); setEditingName(true) }}>Edit</button>
</>
)}
@@ -215,13 +196,57 @@ export default function Profile() {
: <Badge variant="muted" size="sm">Single-user</Badge>
}
</div>
<p className="ne-Profile__subcopy">
Manage your identity details, public activity, and personal saved entities in one place.
</p>
<div className="ne-Profile__identity-grid">
<div className="ne-Profile__identity-item">
<span className="ne-Profile__identity-label">Email</span>
<span className="ne-Profile__identity-value">{resolvedEmail}</span>
</div>
<div className="ne-Profile__identity-item">
<span className="ne-Profile__identity-label">Login</span>
<span className="ne-Profile__identity-value">{loginLabel}</span>
</div>
<label className="ne-Profile__identity-item">
<span className="ne-Profile__identity-label">Country</span>
<select
className="ne-Profile__identity-select"
value={profile.countryCode}
onChange={e => setProfile({ countryCode: e.target.value })}
>
<option value="+91">India (+91)</option>
<option value="+1">US (+1)</option>
<option value="+44">UK (+44)</option>
<option value="+65">Singapore (+65)</option>
</select>
</label>
<label className="ne-Profile__identity-item">
<span className="ne-Profile__identity-label">Mobile</span>
<input
className="ne-Profile__identity-input"
value={profile.mobile ?? ''}
onChange={e => setProfile({ mobile: e.target.value || null })}
placeholder="Optional"
/>
</label>
<label className="ne-Profile__identity-item ne-Profile__identity-item--wide">
<span className="ne-Profile__identity-label">Address</span>
<input
className="ne-Profile__identity-input"
value={profile.address ?? ''}
onChange={e => setProfile({ address: e.target.value || null })}
placeholder="Optional"
/>
</label>
</div>
<div className="ne-Profile__stats">
{[
{ v: myAgents.length, l: 'Agents' },
{ v: myTasks.length, l: 'Tasks' },
{ v: myWorkflows.length, l: 'Workflows' },
{ v: myQuestions.length, l: 'Questions' },
{ v: myPosts.length, l: 'Posts' },
{ v: answerCount, l: 'Answers' },
{ v: myConstellations.length, l: 'Constellations' },
{ v: myOrders.length, l: 'Orders' },
{ v: totalEntities, l: 'Total' },
].map(s => (
<div key={s.l} className="ne-Profile__stat">
@@ -236,158 +261,6 @@ export default function Profile() {
{/* ── Dashboard grid ────────────────────────────── */}
<div className="ne-ProfileDash">
{/* ═══ RUNTIME ═══════════════════════════════════════════════════════ */}
<div className="ne-ProfileDash__category">
<div className="ne-ProfileDash__category-label">Runtime</div>
<div className="ne-ProfileDash__grid">
{/* Agents */}
<Section
icon={<Bot size={13} />} title="Agents" count={myAgents.length}
navTo="/agents" loading={agentLoading}
empty={!agentLoading && myAgents.length === 0}
emptyMsg="No agents yet"
>
{myAgents.slice(0, PREVIEW).map(a => (
<Item key={a.id} onClick={() => navigate('/agents')}
left={<Dot color="bg-indigo-500/10 border border-indigo-500/20" icon={<Bot size={12} className="text-indigo-400" />} />}
title={a.name}
badge={<Badge variant={agentV(a.status) as 'green' | 'amber' | 'muted' | 'red'} size="sm">{a.status}</Badge>}
meta={<MetaRow><span className="font-mono text-[10px]">{a.model}</span><span>{a.execution_count} runs</span>{a.error_count > 0 && <span className="text-red-400">{a.error_count} err</span>}</MetaRow>}
/>
))}
</Section>
{/* Tasks */}
<Section
icon={<ListTodo size={13} />} title="Tasks" count={myTasks.length}
navTo="/tasks" loading={taskLoading}
empty={!taskLoading && myTasks.length === 0}
emptyMsg="No tasks yet"
>
{myTasks.slice(0, PREVIEW).map(t => (
<Item key={t.id} onClick={() => navigate('/tasks')}
left={<Dot color="bg-cyan-500/10 border border-cyan-500/20" icon={<ListTodo size={12} className="text-cyan-400" />} />}
title={<span className="font-mono">{t.task_type}</span>}
badge={<Badge variant={taskV(t.status) as 'green' | 'cyan' | 'amber' | 'muted' | 'red'} size="sm">{t.status}</Badge>}
meta={<MetaRow><span className="font-mono text-[10px] text-zinc-600">{t.id.slice(0, 8)}</span><RelativeTime isoString={t.created_at} /></MetaRow>}
/>
))}
</Section>
{/* Workflows */}
<Section
icon={<GitBranch size={13} />} title="Workflows" count={myWorkflows.length}
loading={wfLoading}
empty={!wfLoading && myWorkflows.length === 0}
emptyMsg="No workflows yet"
>
{myWorkflows.slice(0, PREVIEW).map(w => (
<Item key={w.id}
left={<Dot color="bg-violet-500/10 border border-violet-500/20" icon={<GitBranch size={12} className="text-violet-400" />} />}
title={w.name}
badge={<Badge variant={wfV(w.status) as 'green' | 'amber' | 'muted'} size="sm">{w.status}</Badge>}
meta={<MetaRow><span>{w.execution_count} runs</span>{w.last_execution_at && <RelativeTime isoString={w.last_execution_at} />}</MetaRow>}
/>
))}
</Section>
{/* Scheduled Jobs */}
<Section
icon={<Calendar size={13} />} title="Scheduled Jobs" count={myJobs.length}
navTo="/schedules" loading={jobLoading}
empty={!jobLoading && myJobs.length === 0}
emptyMsg="No jobs yet"
>
{myJobs.slice(0, PREVIEW).map(j => (
<Item key={j.id} onClick={() => navigate('/schedules')}
left={<Dot color="bg-cyan-500/10 border border-cyan-500/20" icon={<Calendar size={12} className="text-cyan-400" />} />}
title={j.name}
badge={<Badge variant={j.is_active ? 'green' : 'muted'} size="sm">{j.is_active ? 'active' : 'paused'}</Badge>}
meta={<MetaRow><span className="font-mono text-[10px] text-indigo-400">{j.cron_expression}</span><span>{j.run_count} runs</span>{j.next_run_at && <RelativeTime isoString={j.next_run_at} />}</MetaRow>}
/>
))}
</Section>
</div>
</div>
{/* ═══ GOVERNANCE ════════════════════════════════════════════════════ */}
<div className="ne-ProfileDash__category">
<div className="ne-ProfileDash__category-label">Governance</div>
<div className="ne-ProfileDash__grid">
{/* Policies — with inline toggle */}
<Section
icon={<Shield size={13} />} title="Policies" count={myPolicies.length}
navTo="/policies" loading={policyLoading}
empty={!policyLoading && myPolicies.length === 0}
emptyMsg="No policies yet"
>
{myPolicies.slice(0, PREVIEW).map(p => (
<div key={p.id} className="ne-ProfileItem">
<div className="ne-ProfileItem__inner">
<div className="ne-ProfileItem__left">
<Dot color="bg-green-500/10 border border-green-500/20" icon={<Shield size={12} className="text-green-400" />} />
</div>
<div className="ne-ProfileItem__body">
<div className="ne-ProfileItem__title-row">
<button className="ne-ProfileItem__title hover:text-indigo-300 transition-colors" onClick={() => navigate('/policies')}>{p.name}</button>
<Badge variant="muted" size="sm">{p.policy_type}</Badge>
</div>
<MetaRow><span>priority {p.priority}</span><RelativeTime isoString={p.created_at} /></MetaRow>
</div>
<button
onClick={() => togglePolicy.mutate({ id: p.id, enabled: !p.enabled })}
disabled={togglePolicy.isPending}
className={`ne-ProfileItem__toggle ${p.enabled ? 'ne-ProfileItem__toggle--on' : 'ne-ProfileItem__toggle--off'}`}
title={p.enabled ? 'Disable' : 'Enable'}
>
{p.enabled ? <ToggleRight size={13} /> : <ToggleLeft size={13} />}
{p.enabled ? 'on' : 'off'}
</button>
</div>
</div>
))}
</Section>
{/* Plugins */}
<Section
icon={<Puzzle size={13} />} title="Plugins" count={myPlugins.length}
navTo="/plugins" loading={pluginLoading}
empty={!pluginLoading && myPlugins.length === 0}
emptyMsg="No plugins yet"
>
{myPlugins.slice(0, PREVIEW).map(p => (
<Item key={p.id} onClick={() => navigate('/plugins')}
left={<Dot color="bg-purple-500/10 border border-purple-500/20" icon={<Puzzle size={12} className="text-purple-400" />} />}
title={<>{p.name} <span className="font-mono text-zinc-600 text-[10px]">v{p.version}</span></>}
badge={<Badge variant={p.enabled ? 'green' : 'muted'} size="sm">{p.enabled ? 'on' : 'off'}</Badge>}
meta={<MetaRow><span>by {p.author}</span><span>{p.install_count} installs</span></MetaRow>}
/>
))}
</Section>
{/* Integrations */}
<Section
icon={<Plug size={13} />} title="Integrations" count={myIntegrations.length}
navTo="/integrations" loading={integLoading}
empty={!integLoading && myIntegrations.length === 0}
emptyMsg="No integrations yet"
>
{myIntegrations.slice(0, PREVIEW).map(i => (
<Item key={i.id} onClick={() => navigate('/integrations')}
left={<Dot color="bg-amber-500/10 border border-amber-500/20" icon={<Plug size={12} className="text-amber-400" />} />}
title={i.name}
badge={<Badge variant={integV(i.status) as 'green' | 'amber' | 'muted' | 'red'} size="sm">{i.status}</Badge>}
meta={<MetaRow><span className="text-zinc-500">{i.provider_name}</span><span className="capitalize">{i.category}</span></MetaRow>}
/>
))}
</Section>
</div>
</div>
{/* ═══ COMMUNITY ═════════════════════════════════════════════════════ */}
<div className="ne-ProfileDash__category">
<div className="ne-ProfileDash__category-label">Community</div>
@@ -434,9 +307,9 @@ export default function Profile() {
{/* Answers */}
<Section
icon={<CheckCircle2 size={13} />} title="Answers" count={myQuestions.filter(q => q.answer_count > 0).length}
icon={<CheckCircle2 size={13} />} title="Answers" count={answerCount}
navTo="/qna" loading={qnaLoading}
empty={!qnaLoading && myQuestions.filter(q => q.answer_count > 0).length === 0}
empty={!qnaLoading && answerCount === 0}
emptyMsg="No answered questions yet"
>
{myQuestions.filter(q => q.answer_count > 0).slice(0, PREVIEW).map(q => (
@@ -451,7 +324,7 @@ export default function Profile() {
{/* Comments */}
<Section
icon={<MessageCircle size={13} />} title="Comments"
count={myQuestions.length + myPosts.filter(p => p.comment_count > 0).length}
count={commentActivityCount}
loading={qnaLoading || blogLoading}
empty={!qnaLoading && !blogLoading && myQuestions.length === 0 && myPosts.length === 0}
emptyMsg="No comment activity yet"
@@ -477,9 +350,9 @@ export default function Profile() {
</div>
</div>
{/* ═══ ACQUISITIONS ══════════════════════════════════════════════ */}
{/* ═══ SAVED & PURCHASED ══════════════════════════════════════════ */}
<div className="ne-ProfileDash__category">
<div className="ne-ProfileDash__category-label">Acquisitions</div>
<div className="ne-ProfileDash__category-label">Saved & purchased</div>
<div className="ne-ProfileDash__grid">
{/* Orders */}