Remove remaining mock data: fake user identity, notification badge, system status

TopBar, Sidebar, and the Settings profile form previously showed a hardcoded
"ArchNest Ops" identity, a fake unread-notification count, and a static "All
Systems Operational" indicator. These now use the real logged-in user (with
a new PUT /api/auth/me endpoint to edit display name/email/avatar) and real
integration health for the sidebar status dot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
Claude 2026-06-18 20:08:30 +00:00
parent 3b920fcfb2
commit 49c49635a9
No known key found for this signature in database
6 changed files with 114 additions and 46 deletions

View file

@ -57,4 +57,26 @@ export async function authRoutes(app: FastifyInstance) {
.get(payload.sub)
return { user }
})
const profileUpdateSchema = z.object({
displayName: z.string().max(128).nullable().optional(),
email: z.string().email().max(256).nullable().optional(),
avatarDataUrl: z.string().max(2_000_000).nullable().optional(),
})
app.put('/api/auth/me', { onRequest: [app.authenticate] }, async (req, reply) => {
const payload = req.user as { sub: number; username: string }
const parsed = profileUpdateSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const { displayName, email, avatarDataUrl } = parsed.data
if (displayName !== undefined) db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(displayName, payload.sub)
if (email !== undefined) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, payload.sub)
if (avatarDataUrl !== undefined) db.prepare('UPDATE users SET avatar_data_url = ? WHERE id = ?').run(avatarDataUrl, payload.sub)
const user = db
.prepare('SELECT id, username, display_name, email, avatar_data_url FROM users WHERE id = ?')
.get(payload.sub)
return { user }
})
}

View file

@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { useLocation, Link } from 'react-router-dom'
import {
LayoutGrid,
@ -8,6 +9,7 @@ import {
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { api, type Integration } from '../lib/api'
interface SidebarProps {
collapsed: boolean
@ -25,6 +27,16 @@ const navItems = [
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const width = collapsed ? 64 : 200
const location = useLocation()
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
useEffect(() => {
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
}, [])
const errored = integrations?.filter((i) => i.status === 'error').length ?? 0
const statusOk = errored === 0
const statusColor = statusOk ? '#2ECC71' : '#E74C3C'
const statusLabel = integrations === null ? 'Checking…' : statusOk ? 'All Systems Operational' : `${errored} Issue${errored > 1 ? 's' : ''} Detected`
return (
<aside
@ -116,11 +128,11 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
}}
>
<div className="flex items-center gap-2">
<div style={{ width: '12px', height: '12px', borderRadius: '50%', border: '2px solid #2ECC71', flexShrink: 0 }} />
<div style={{ width: '12px', height: '12px', borderRadius: '50%', border: `2px solid ${statusColor}`, flexShrink: 0 }} />
{!collapsed && (
<div>
<span style={{ fontSize: '9px', color: '#E8E6E0', display: 'block', lineHeight: 1.3, fontWeight: 500 }}>System Status</span>
<span style={{ fontSize: '8px', color: '#2ECC71', display: 'block', lineHeight: 1.3 }}>All Systems Operational</span>
<span style={{ fontSize: '8px', color: statusColor, display: 'block', lineHeight: 1.3 }}>{statusLabel}</span>
</div>
)}
</div>

View file

@ -16,13 +16,21 @@ const pageSubtitles: Record<string, string> = {
}
export default function TopBar() {
const { logout } = useAuth()
const { logout, user } = useAuth()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const location = useLocation()
const title = pageTitles[location.pathname] ?? 'Glance'
const subtitle = pageSubtitles[location.pathname]
const displayName = user?.display_name || user?.username || ''
const initials = displayName
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase()
useEffect(() => {
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
@ -69,9 +77,6 @@ export default function TopBar() {
{/* Notifications */}
<button className="relative p-1.5 text-text-secondary hover:text-gold transition-colors bg-transparent border-none cursor-pointer">
<Bell size={17} />
<span className="absolute -top-0.5 -right-1 w-3.5 h-3.5 bg-danger rounded-full text-[8px] text-white flex items-center justify-center font-bold">
3
</span>
</button>
{/* User Avatar + Dropdown */}
@ -80,11 +85,15 @@ export default function TopBar() {
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 bg-transparent border-none cursor-pointer p-0"
>
<div className="w-9 h-9 rounded-full border-2 border-gold bg-card flex items-center justify-center text-gold font-bold text-[12px] shadow-[0_0_8px_rgba(200,164,52,0.4)]">
AO
<div className="w-9 h-9 rounded-full border-2 border-gold bg-card flex items-center justify-center text-gold font-bold text-[12px] shadow-[0_0_8px_rgba(200,164,52,0.4)] overflow-hidden">
{user?.avatar_data_url ? (
<img src={user.avatar_data_url} alt={displayName} className="w-full h-full object-cover" />
) : (
initials
)}
</div>
<div className="flex flex-col text-left">
<span className="text-[12px] text-text-primary font-medium leading-tight">ArchNest Ops</span>
<span className="text-[12px] text-text-primary font-medium leading-tight">{displayName}</span>
<span className="text-[9px] text-text-secondary leading-tight">Administrator</span>
</div>
<ChevronDown size={12} className={`text-text-secondary transition-transform duration-200 ${userMenuOpen ? 'rotate-180' : ''}`} />
@ -93,8 +102,8 @@ export default function TopBar() {
{userMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 bg-card border border-border rounded-xl overflow-hidden shadow-lg z-50">
<div className="p-3 border-b border-border">
<p className="text-[12px] text-text-primary font-medium">ArchNest Ops</p>
<p className="text-[10px] text-text-secondary">admin@archnest.io</p>
<p className="text-[12px] text-text-primary font-medium">{displayName}</p>
<p className="text-[10px] text-text-secondary">{user?.email || user?.username}</p>
</div>
<div className="py-1">
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-text-secondary hover:text-text-primary hover:bg-page transition-colors no-underline">

View file

@ -1,16 +1,8 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, getToken, setToken } from './api'
import { api, getToken, setToken, type AuthUser } from './api'
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in'
interface AuthUser {
id: number
username: string
display_name: string | null
email: string | null
avatar_data_url: string | null
}
interface AuthContextValue {
status: AuthStatus
user: AuthUser | null
@ -18,6 +10,7 @@ interface AuthContextValue {
completeSetup: (username: string, password: string) => Promise<void>
finishEnrollment: () => Promise<void>
logout: () => void
setUser: (user: AuthUser) => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
@ -70,7 +63,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
return (
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout }}>
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout, setUser }}>
{children}
</AuthContext.Provider>
)

View file

@ -48,7 +48,9 @@ export const api = {
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
login: (username: string, password: string) =>
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => apiFetch<{ user: { id: number; username: string; display_name: string | null; email: string | null; avatar_data_url: string | null } }>('/auth/me'),
me: () => apiFetch<{ user: AuthUser }>('/auth/me'),
updateMe: (data: Partial<{ displayName: string | null; email: string | null; avatarDataUrl: string | null }>) =>
apiFetch<{ user: AuthUser }>('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
@ -72,6 +74,14 @@ export const api = {
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
}
export interface AuthUser {
id: number
username: string
display_name: string | null
email: string | null
avatar_data_url: string | null
}
export interface Integration {
id: number
type: string

View file

@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { api, ApiError, type Integration } from '../lib/api'
import { useAuth } from '../lib/AuthContext'
import {
User,
Palette,
@ -114,9 +115,11 @@ function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
)
}
function GoldButton({ children, danger }: { children: React.ReactNode; danger?: boolean }) {
function GoldButton({ children, danger, onClick, disabled }: { children: React.ReactNode; danger?: boolean; onClick?: () => void; disabled?: boolean }) {
return (
<button
onClick={onClick}
disabled={disabled}
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style={{
fontSize: '12px',
@ -127,6 +130,7 @@ function GoldButton({ children, danger }: { children: React.ReactNode; danger?:
borderRadius: '8px',
padding: '9px 16px',
boxShadow: danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)',
opacity: disabled ? 0.6 : 1,
}}
>
{children}
@ -135,8 +139,21 @@ function GoldButton({ children, danger }: { children: React.ReactNode; danger?:
}
function ProfileSection() {
const [avatar, setAvatar] = useState<string | null>(null)
const { user, setUser } = useAuth()
const fileInputRef = useRef<HTMLInputElement>(null)
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [email, setEmail] = useState(user?.email ?? '')
const [avatar, setAvatar] = useState<string | null>(user?.avatar_data_url ?? null)
const [saving, setSaving] = useState(false)
const [savedMsg, setSavedMsg] = useState('')
useEffect(() => {
setDisplayName(user?.display_name ?? '')
setEmail(user?.email ?? '')
setAvatar(user?.avatar_data_url ?? null)
}, [user])
const initials = (displayName || user?.username || '?').slice(0, 2).toUpperCase()
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
@ -146,6 +163,20 @@ function ProfileSection() {
reader.readAsDataURL(file)
}
async function handleSave() {
setSaving(true)
setSavedMsg('')
try {
const { user: updated } = await api.updateMe({ displayName, email, avatarDataUrl: avatar })
setUser(updated)
setSavedMsg('Saved')
} catch (err) {
setSavedMsg(err instanceof ApiError ? err.message : 'Failed to save')
} finally {
setSaving(false)
}
}
return (
<div style={cardBase}>
<h3 style={sectionTitle}>Profile</h3>
@ -167,7 +198,7 @@ function ProfileSection() {
}}
title="Upload photo"
>
{!avatar && 'AO'}
{!avatar && initials}
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
@ -177,37 +208,28 @@ function ProfileSection() {
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
<div>
<div className="flex items-center gap-2">
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>ArchNest Ops</span>
<span style={{ fontSize: '10px', color: '#C8A434', border: '1px solid rgba(200,164,52,0.3)', borderRadius: '6px', padding: '2px 8px' }}>
Administrator
</span>
</div>
<span style={{ fontSize: '12px', color: '#7A7D85' }}>admin@archnest.io</span>
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>{displayName || user?.username}</span>
<br />
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{email || 'No email set'}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4" style={{ marginBottom: '20px' }}>
<div>
<label style={labelStyle}>Display Name</label>
<input style={inputStyle} defaultValue="ArchNest Ops" />
<input style={inputStyle} value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div>
<div>
<label style={labelStyle}>Email</label>
<input style={inputStyle} defaultValue="admin@archnest.io" />
</div>
<div>
<label style={labelStyle}>Role</label>
<input style={inputStyle} defaultValue="Administrator" disabled />
</div>
<div>
<label style={labelStyle}>Timezone</label>
<input style={inputStyle} defaultValue="America/New_York (EST)" />
<input style={inputStyle} value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
</div>
<GoldButton>
<div className="flex items-center gap-3">
<GoldButton onClick={handleSave} disabled={saving}>
<Check size={14} />
Save Changes
{saving ? 'Saving…' : 'Save Changes'}
</GoldButton>
{savedMsg && <span style={{ fontSize: '12px', color: '#7A7D85' }}>{savedMsg}</span>}
</div>
</div>
)
}