From 7ed0f4b1032f4428136dd2af06ec56203913ad1e Mon Sep 17 00:00:00 2001
From: Zak Bearman
Date: Tue, 1 Sep 2026 17:04:25 +1200
Subject: [PATCH] Add global admin user-view preview
---
frontend/app/admin/invites/page.tsx | 5 --
frontend/app/globals.css | 101 ++++++++++++++++++++++++++
frontend/app/layout.tsx | 6 +-
frontend/app/lib/viewMode.ts | 48 ++++++++++++
frontend/app/profile/invites/page.tsx | 19 +----
frontend/app/ui/HeaderIdentity.tsx | 83 +++++++++++++--------
frontend/app/ui/UserViewBanner.tsx | 21 ++++++
7 files changed, 228 insertions(+), 55 deletions(-)
create mode 100644 frontend/app/lib/viewMode.ts
create mode 100644 frontend/app/ui/UserViewBanner.tsx
diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx
index 0354bbd..c87234a 100644
--- a/frontend/app/admin/invites/page.tsx
+++ b/frontend/app/admin/invites/page.tsx
@@ -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={
-
- }
>
{error && {error}
}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index de38290..2c07192 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -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;
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index e3323bb..101153a 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -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 }) {
+
{children}
diff --git a/frontend/app/lib/viewMode.ts b/frontend/app/lib/viewMode.ts
new file mode 100644
index 0000000..f3eaca5
--- /dev/null
+++ b/frontend/app/lib/viewMode.ts
@@ -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
+}
diff --git a/frontend/app/profile/invites/page.tsx b/frontend/app/profile/invites/page.tsx
index bee4440..2090650 100644
--- a/frontend/app/profile/invites/page.tsx
+++ b/frontend/app/profile/invites/page.tsx
@@ -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(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 Loading invite tools...
}
@@ -330,16 +325,6 @@ export default function ProfileInvitesPage() {
- {profile?.role === 'admin' && !viewAsUser ? (
-
- ) : null}
- {profile?.role === 'admin' && viewAsUser ? (
-
- ) : null}
diff --git a/frontend/app/ui/HeaderIdentity.tsx b/frontend/app/ui/HeaderIdentity.tsx
index 0ba8025..8180dac 100644
--- a/frontend/app/ui/HeaderIdentity.tsx
+++ b/frontend/app/ui/HeaderIdentity.tsx
@@ -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
(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,39 +64,54 @@ export default function HeaderIdentity() {
}
return (
-
-
- {open && (
-
-
Signed in as {label}
-
)
}
diff --git a/frontend/app/ui/UserViewBanner.tsx b/frontend/app/ui/UserViewBanner.tsx
new file mode 100644
index 0000000..e41515a
--- /dev/null
+++ b/frontend/app/ui/UserViewBanner.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
+
+export default function UserViewBanner() {
+ const enabled = useUserViewPreview()
+
+ if (!enabled) return null
+
+ return (
+
+
+ User view
+ You are previewing the non-admin experience. Your account and backend permissions remain admin.
+
+
+
+ )
+}