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:
parent
3b920fcfb2
commit
49c49635a9
6 changed files with 114 additions and 46 deletions
|
|
@ -57,4 +57,26 @@ export async function authRoutes(app: FastifyInstance) {
|
||||||
.get(payload.sub)
|
.get(payload.sub)
|
||||||
return { user }
|
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 }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation, Link } from 'react-router-dom'
|
import { useLocation, Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { api, type Integration } from '../lib/api'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
|
|
@ -25,6 +27,16 @@ const navItems = [
|
||||||
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||||
const width = collapsed ? 64 : 200
|
const width = collapsed ? 64 : 200
|
||||||
const location = useLocation()
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|
@ -116,11 +128,11 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{!collapsed && (
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontSize: '9px', color: '#E8E6E0', display: 'block', lineHeight: 1.3, fontWeight: 500 }}>System Status</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,21 @@ const pageSubtitles: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { logout } = useAuth()
|
const { logout, user } = useAuth()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = pageTitles[location.pathname] ?? 'Glance'
|
const title = pageTitles[location.pathname] ?? 'Glance'
|
||||||
const subtitle = pageSubtitles[location.pathname]
|
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(() => {
|
useEffect(() => {
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
|
@ -69,9 +77,6 @@ export default function TopBar() {
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button className="relative p-1.5 text-text-secondary hover:text-gold transition-colors bg-transparent border-none cursor-pointer">
|
<button className="relative p-1.5 text-text-secondary hover:text-gold transition-colors bg-transparent border-none cursor-pointer">
|
||||||
<Bell size={17} />
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* User Avatar + Dropdown */}
|
{/* User Avatar + Dropdown */}
|
||||||
|
|
@ -80,11 +85,15 @@ export default function TopBar() {
|
||||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||||
className="flex items-center gap-2 bg-transparent border-none cursor-pointer p-0"
|
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)]">
|
<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">
|
||||||
AO
|
{user?.avatar_data_url ? (
|
||||||
|
<img src={user.avatar_data_url} alt={displayName} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
initials
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col text-left">
|
<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>
|
<span className="text-[9px] text-text-secondary leading-tight">Administrator</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown size={12} className={`text-text-secondary transition-transform duration-200 ${userMenuOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown size={12} className={`text-text-secondary transition-transform duration-200 ${userMenuOpen ? 'rotate-180' : ''}`} />
|
||||||
|
|
@ -93,8 +102,8 @@ export default function TopBar() {
|
||||||
{userMenuOpen && (
|
{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="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">
|
<div className="p-3 border-b border-border">
|
||||||
<p className="text-[12px] text-text-primary font-medium">ArchNest Ops</p>
|
<p className="text-[12px] text-text-primary font-medium">{displayName}</p>
|
||||||
<p className="text-[10px] text-text-secondary">admin@archnest.io</p>
|
<p className="text-[10px] text-text-secondary">{user?.email || user?.username}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<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">
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
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'
|
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 {
|
interface AuthContextValue {
|
||||||
status: AuthStatus
|
status: AuthStatus
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
|
|
@ -18,6 +10,7 @@ interface AuthContextValue {
|
||||||
completeSetup: (username: string, password: string) => Promise<void>
|
completeSetup: (username: string, password: string) => Promise<void>
|
||||||
finishEnrollment: () => Promise<void>
|
finishEnrollment: () => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
setUser: (user: AuthUser) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
@ -70,7 +63,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout }}>
|
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout, setUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ export const api = {
|
||||||
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
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'),
|
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
|
||||||
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
|
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'),
|
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 {
|
export interface Integration {
|
||||||
id: number
|
id: number
|
||||||
type: string
|
type: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api, ApiError, type Integration } from '../lib/api'
|
import { api, ApiError, type Integration } from '../lib/api'
|
||||||
|
import { useAuth } from '../lib/AuthContext'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Palette,
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
||||||
style={{
|
style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
|
|
@ -127,6 +130,7 @@ function GoldButton({ children, danger }: { children: React.ReactNode; danger?:
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '9px 16px',
|
padding: '9px 16px',
|
||||||
boxShadow: danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)',
|
boxShadow: danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)',
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -135,8 +139,21 @@ function GoldButton({ children, danger }: { children: React.ReactNode; danger?:
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileSection() {
|
function ProfileSection() {
|
||||||
const [avatar, setAvatar] = useState<string | null>(null)
|
const { user, setUser } = useAuth()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
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>) {
|
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
|
|
@ -146,6 +163,20 @@ function ProfileSection() {
|
||||||
reader.readAsDataURL(file)
|
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 (
|
return (
|
||||||
<div style={cardBase}>
|
<div style={cardBase}>
|
||||||
<h3 style={sectionTitle}>Profile</h3>
|
<h3 style={sectionTitle}>Profile</h3>
|
||||||
|
|
@ -167,7 +198,7 @@ function ProfileSection() {
|
||||||
}}
|
}}
|
||||||
title="Upload photo"
|
title="Upload photo"
|
||||||
>
|
>
|
||||||
{!avatar && 'AO'}
|
{!avatar && initials}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
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)' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||||
|
|
@ -177,37 +208,28 @@ function ProfileSection() {
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>{displayName || user?.username}</span>
|
||||||
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>ArchNest Ops</span>
|
<br />
|
||||||
<span style={{ fontSize: '10px', color: '#C8A434', border: '1px solid rgba(200,164,52,0.3)', borderRadius: '6px', padding: '2px 8px' }}>
|
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{email || 'No email set'}</span>
|
||||||
Administrator
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '12px', color: '#7A7D85' }}>admin@archnest.io</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4" style={{ marginBottom: '20px' }}>
|
<div className="grid grid-cols-2 gap-4" style={{ marginBottom: '20px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Display Name</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Email</label>
|
<label style={labelStyle}>Email</label>
|
||||||
<input style={inputStyle} defaultValue="admin@archnest.io" />
|
<input style={inputStyle} value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
</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)" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GoldButton>
|
<div className="flex items-center gap-3">
|
||||||
|
<GoldButton onClick={handleSave} disabled={saving}>
|
||||||
<Check size={14} />
|
<Check size={14} />
|
||||||
Save Changes
|
{saving ? 'Saving…' : 'Save Changes'}
|
||||||
</GoldButton>
|
</GoldButton>
|
||||||
|
{savedMsg && <span style={{ fontSize: '12px', color: '#7A7D85' }}>{savedMsg}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue