Add global admin user-view preview
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user