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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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', {}),
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const _default: AuthMode = {
|
||||
isMultiUser: false,
|
||||
iamEnabled: false,
|
||||
iamIssuer: null,
|
||||
providerName: null,
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user