diff --git a/src/api/middleware/auth.py b/src/api/middleware/auth.py index b4d85c09..c9fd2777 100644 --- a/src/api/middleware/auth.py +++ b/src/api/middleware/auth.py @@ -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. diff --git a/src/api/routers/auth_mode.py b/src/api/routers/auth_mode.py index cc16bb42..caff8bde 100644 --- a/src/api/routers/auth_mode.py +++ b/src/api/routers/auth_mode.py @@ -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, ) diff --git a/webapp/src/api/client.ts b/webapp/src/api/client.ts index 77eea234..90741829 100644 --- a/webapp/src/api/client.ts +++ b/webapp/src/api/client.ts @@ -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(`/legal-documents/${documentType}`), +} + export const tncApi = { status: () => get('/tnc/status'), accept: () => post('/tnc/accept', {}), diff --git a/webapp/src/auth/ProtectedRoute.tsx b/webapp/src/auth/ProtectedRoute.tsx index a8f9ed64..65239e30 100644 --- a/webapp/src/auth/ProtectedRoute.tsx +++ b/webapp/src/auth/ProtectedRoute.tsx @@ -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 && ( logout()} /> diff --git a/webapp/src/auth/authDebug.ts b/webapp/src/auth/authDebug.ts index 20984018..d1041e93 100644 --- a/webapp/src/auth/authDebug.ts +++ b/webapp/src/auth/authDebug.ts @@ -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 | 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 { + 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 { - // 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 simulateFullExpiry: () => void forceRefresh: () => Promise } @@ -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', ) } diff --git a/webapp/src/components/layout/Layout.tsx b/webapp/src/components/layout/Layout.tsx index 71d3ee24..916cf6d8 100644 --- a/webapp/src/components/layout/Layout.tsx +++ b/webapp/src/components/layout/Layout.tsx @@ -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() {
+
diff --git a/webapp/src/hooks/useAuthMode.ts b/webapp/src/hooks/useAuthMode.ts index 6b6e488a..c4447614 100644 --- a/webapp/src/hooks/useAuthMode.ts +++ b/webapp/src/hooks/useAuthMode.ts @@ -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, } } diff --git a/webapp/src/hooks/useAuthModeContext.ts b/webapp/src/hooks/useAuthModeContext.ts index 8e04d2d4..36eeea6e 100644 --- a/webapp/src/hooks/useAuthModeContext.ts +++ b/webapp/src/hooks/useAuthModeContext.ts @@ -5,6 +5,7 @@ const _default: AuthMode = { isMultiUser: false, iamEnabled: false, iamIssuer: null, + providerName: null, isLoading: false, } diff --git a/webapp/src/hooks/useProfile.ts b/webapp/src/hooks/useProfile.ts index cdd47cf4..a2b7d0f5 100644 --- a/webapp/src/hooks/useProfile.ts +++ b/webapp/src/hooks/useProfile.ts @@ -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 { diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 5ae44f49..5e12ac7e 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -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( - + + + , diff --git a/webapp/src/pages/Profile.tsx b/webapp/src/pages/Profile.tsx index b7a19d03..9e7486da 100644 --- a/webapp/src/pages/Profile.tsx +++ b/webapp/src/pages/Profile.tsx @@ -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(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)[s] ?? 'muted' const blogV = (s: string) => ({ published: 'green', draft: 'amber', archived: 'muted' } as Record)[s] ?? 'muted' - const agentV = (s: string) => ({ running: 'green', idle: 'muted', error: 'red', paused: 'amber' } as Record)[s] ?? 'muted' - const taskV = (s: string) => ({ completed: 'green', running: 'cyan', failed: 'red', pending: 'amber', cancelled: 'muted' } as Record)[s] ?? 'muted' - const integV = (s: string) => ({ connected: 'green', error: 'red', connecting: 'amber', disconnected: 'muted' } as Record)[s] ?? 'muted' - const wfV = (s: string) => ({ active: 'green', draft: 'amber', archived: 'muted' } as Record)[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 ( - } title="Profile" subtitle="Your activity on NebulaOS" /> + } title="Profile" subtitle="Account details and personal activity" />
@@ -204,7 +185,7 @@ export default function Profile() { /> ) : ( <> - {profile.name} + {resolvedName} )} @@ -215,13 +196,57 @@ export default function Profile() { : Single-user }
+

+ Manage your identity details, public activity, and personal saved entities in one place. +

+
+
+ Email + {resolvedEmail} +
+
+ Login + {loginLabel} +
+ + + +
{[ - { 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 => (
@@ -236,158 +261,6 @@ export default function Profile() { {/* ── Dashboard grid ────────────────────────────── */}
- {/* ═══ RUNTIME ═══════════════════════════════════════════════════════ */} -
-
Runtime
-
- - {/* Agents */} -
} title="Agents" count={myAgents.length} - navTo="/agents" loading={agentLoading} - empty={!agentLoading && myAgents.length === 0} - emptyMsg="No agents yet" - > - {myAgents.slice(0, PREVIEW).map(a => ( - navigate('/agents')} - left={} />} - title={a.name} - badge={{a.status}} - meta={{a.model}{a.execution_count} runs{a.error_count > 0 && {a.error_count} err}} - /> - ))} -
- - {/* Tasks */} -
} title="Tasks" count={myTasks.length} - navTo="/tasks" loading={taskLoading} - empty={!taskLoading && myTasks.length === 0} - emptyMsg="No tasks yet" - > - {myTasks.slice(0, PREVIEW).map(t => ( - navigate('/tasks')} - left={} />} - title={{t.task_type}} - badge={{t.status}} - meta={{t.id.slice(0, 8)}…} - /> - ))} -
- - {/* Workflows */} -
} title="Workflows" count={myWorkflows.length} - loading={wfLoading} - empty={!wfLoading && myWorkflows.length === 0} - emptyMsg="No workflows yet" - > - {myWorkflows.slice(0, PREVIEW).map(w => ( - } />} - title={w.name} - badge={{w.status}} - meta={{w.execution_count} runs{w.last_execution_at && }} - /> - ))} -
- - {/* Scheduled Jobs */} -
} 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 => ( - navigate('/schedules')} - left={} />} - title={j.name} - badge={{j.is_active ? 'active' : 'paused'}} - meta={{j.cron_expression}{j.run_count} runs{j.next_run_at && }} - /> - ))} -
- -
-
- - {/* ═══ GOVERNANCE ════════════════════════════════════════════════════ */} -
-
Governance
-
- - {/* Policies — with inline toggle */} -
} title="Policies" count={myPolicies.length} - navTo="/policies" loading={policyLoading} - empty={!policyLoading && myPolicies.length === 0} - emptyMsg="No policies yet" - > - {myPolicies.slice(0, PREVIEW).map(p => ( -
-
-
- } /> -
-
-
- - {p.policy_type} -
- priority {p.priority} -
- -
-
- ))} -
- - {/* Plugins */} -
} title="Plugins" count={myPlugins.length} - navTo="/plugins" loading={pluginLoading} - empty={!pluginLoading && myPlugins.length === 0} - emptyMsg="No plugins yet" - > - {myPlugins.slice(0, PREVIEW).map(p => ( - navigate('/plugins')} - left={} />} - title={<>{p.name} v{p.version}} - badge={{p.enabled ? 'on' : 'off'}} - meta={by {p.author}{p.install_count} installs} - /> - ))} -
- - {/* Integrations */} -
} title="Integrations" count={myIntegrations.length} - navTo="/integrations" loading={integLoading} - empty={!integLoading && myIntegrations.length === 0} - emptyMsg="No integrations yet" - > - {myIntegrations.slice(0, PREVIEW).map(i => ( - navigate('/integrations')} - left={} />} - title={i.name} - badge={{i.status}} - meta={{i.provider_name}{i.category}} - /> - ))} -
- -
-
- {/* ═══ COMMUNITY ═════════════════════════════════════════════════════ */}
Community
@@ -434,9 +307,9 @@ export default function Profile() { {/* Answers */}
} title="Answers" count={myQuestions.filter(q => q.answer_count > 0).length} + icon={} 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 */}
} 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() {
- {/* ═══ ACQUISITIONS ══════════════════════════════════════════════ */} + {/* ═══ SAVED & PURCHASED ══════════════════════════════════════════ */}
-
Acquisitions
+
Saved & purchased
{/* Orders */}