Add global admin user-view preview
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 22s

This commit is contained in:
2026-09-01 17:04:25 +12:00
parent b3c41f6dea
commit 7ed0f4b103
7 changed files with 228 additions and 55 deletions
-5
View File
@@ -1174,11 +1174,6 @@ export default function AdminInviteManagementPage() {
title="Invites"
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
rail={inviteManagementRail}
actions={
<button type="button" className="ghost-button" onClick={() => router.push('/profile/invites?view=user')}>
View as user
</button>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
+101
View File
@@ -181,6 +181,107 @@ body {
align-items: center;
}
.signed-in-context {
display: inline-flex;
align-items: center;
gap: 10px;
}
.user-view-toggle {
min-height: 34px;
padding: 7px 12px;
border: 1px solid rgba(126, 215, 255, 0.28);
border-radius: 999px;
background: rgba(14, 165, 233, 0.08);
color: #bfeaff;
box-shadow: none;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.user-view-toggle.is-active {
border-color: rgba(251, 191, 36, 0.5);
background: rgba(245, 158, 11, 0.13);
color: #fde68a;
}
.user-view-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 14px 0;
padding: 12px 16px;
border: 1px solid rgba(251, 191, 36, 0.38);
border-radius: 10px;
background: linear-gradient(90deg, rgba(245, 158, 11, 0.13), rgba(14, 165, 233, 0.06));
color: #f8e7b2;
}
.user-view-banner > div {
display: flex;
align-items: baseline;
gap: 12px;
}
.user-view-banner strong {
color: #fde68a;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.user-view-banner span {
color: var(--muted);
}
.user-view-banner button {
flex: 0 0 auto;
padding: 8px 12px;
border-color: rgba(251, 191, 36, 0.32);
background: rgba(245, 158, 11, 0.12);
color: #fde68a;
box-shadow: none;
}
.signed-in-header span {
display: block;
margin-top: 4px;
color: #fde68a;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
@media (max-width: 720px) {
.signed-in-context {
margin-left: auto;
}
.user-view-toggle {
min-height: 30px;
padding: 5px 9px;
font-size: 0.64rem;
}
.user-view-banner {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.user-view-banner > div {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.user-view-banner button {
width: 100%;
}
}
.avatar-button {
width: 44px;
height: 44px;
+4 -2
View File
@@ -1,11 +1,12 @@
import './globals.css'
import './ops-redesign.css'
import type { ReactNode } from 'react'
import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity'
import BrandingFavicon from './ui/BrandingFavicon'
import BrandingLogo from './ui/BrandingLogo'
import HeaderActions from './ui/HeaderActions'
import HeaderIdentity from './ui/HeaderIdentity'
import SiteStatus from './ui/SiteStatus'
import UserViewBanner from './ui/UserViewBanner'
export const metadata = {
title: 'Magent',
@@ -36,6 +37,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<HeaderActions />
</div>
</header>
<UserViewBanner />
<SiteStatus />
{children}
</div>
+48
View File
@@ -0,0 +1,48 @@
'use client'
import { useEffect, useState } from 'react'
const USER_VIEW_STORAGE_KEY = 'magent_user_view_preview'
const USER_VIEW_EVENT = 'magent:user-view-change'
const readUserViewPreview = () => {
if (typeof window === 'undefined') return false
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === '1'
}
const applyDocumentMode = (enabled: boolean) => {
if (typeof document === 'undefined') return
document.documentElement.dataset.userView = enabled ? 'true' : 'false'
}
export const setUserViewPreview = (enabled: boolean) => {
if (typeof window === 'undefined') return
if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, '1')
} else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY)
}
applyDocumentMode(enabled)
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }))
}
export const useUserViewPreview = () => {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
const sync = () => {
const nextValue = readUserViewPreview()
applyDocumentMode(nextValue)
setEnabled(nextValue)
}
sync()
window.addEventListener(USER_VIEW_EVENT, sync)
window.addEventListener('storage', sync)
return () => {
window.removeEventListener(USER_VIEW_EVENT, sync)
window.removeEventListener('storage', sync)
}
}, [])
return enabled
}
+2 -17
View File
@@ -3,6 +3,7 @@
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import { useUserViewPreview } from '../../lib/viewMode'
type ProfileInfo = {
username: string
@@ -97,7 +98,7 @@ export default function ProfileInvitesPage() {
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
const [loading, setLoading] = useState(true)
const [viewAsUser, setViewAsUser] = useState(false)
const viewAsUser = useUserViewPreview()
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
@@ -136,7 +137,6 @@ export default function ProfileInvitesPage() {
}
useEffect(() => {
setViewAsUser(new URLSearchParams(window.location.search).get('view') === 'user')
if (!getToken()) {
router.push('/login')
return
@@ -309,11 +309,6 @@ export default function ProfileInvitesPage() {
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
const enterUserView = () => {
setViewAsUser(true)
router.replace('/profile/invites?view=user')
}
if (loading) {
return <main className="card">Loading invite tools...</main>
}
@@ -330,16 +325,6 @@ export default function ProfileInvitesPage() {
</p>
</div>
<div className="admin-inline-actions">
{profile?.role === 'admin' && !viewAsUser ? (
<button type="button" className="ghost-button" onClick={enterUserView}>
View as user
</button>
) : null}
{profile?.role === 'admin' && viewAsUser ? (
<button type="button" onClick={() => router.push('/admin/invites')}>
Return to admin view
</button>
) : null}
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
Back to profile
</button>
+22 -1
View File
@@ -2,11 +2,13 @@
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
const [buildNumber, setBuildNumber] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const viewAsUser = useUserViewPreview()
useEffect(() => {
const token = getToken()
@@ -27,6 +29,9 @@ export default function HeaderIdentity() {
const data = await response.json()
if (data?.username) {
setIdentity({ username: data.username, role: data.role })
if (data.role !== 'admin') {
setUserViewPreview(false)
}
}
const siteResponse = await fetch(`${baseUrl}/site/public`)
if (siteResponse.ok) {
@@ -50,6 +55,7 @@ export default function HeaderIdentity() {
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = async () => {
setUserViewPreview(false)
await logout().catch(() => undefined)
clearToken()
if (typeof window !== 'undefined') {
@@ -58,6 +64,17 @@ export default function HeaderIdentity() {
}
return (
<div className="signed-in-context">
{identity.role === 'admin' ? (
<button
type="button"
className={`user-view-toggle ${viewAsUser ? 'is-active' : ''}`}
aria-pressed={viewAsUser}
onClick={() => setUserViewPreview(!viewAsUser)}
>
{viewAsUser ? 'Exit user view' : 'View as user'}
</button>
) : null}
<div className="signed-in-menu">
<button
type="button"
@@ -71,7 +88,10 @@ export default function HeaderIdentity() {
</button>
{open && (
<div className="signed-in-dropdown">
<div className="signed-in-header">Signed in as {label}</div>
<div className="signed-in-header">
Signed in as {label}
{viewAsUser ? <span>Previewing user view</span> : null}
</div>
<div className="signed-in-actions">
<a href="/profile" onClick={() => setOpen(false)}>
My profile
@@ -92,5 +112,6 @@ export default function HeaderIdentity() {
</div>
)}
</div>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
'use client'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
export default function UserViewBanner() {
const enabled = useUserViewPreview()
if (!enabled) return null
return (
<div className="user-view-banner" role="status">
<div>
<strong>User view</strong>
<span>You are previewing the non-admin experience. Your account and backend permissions remain admin.</span>
</div>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
)
}