Add global admin user-view preview
This commit is contained in:
@@ -1174,11 +1174,6 @@ export default function AdminInviteManagementPage() {
|
|||||||
title="Invites"
|
title="Invites"
|
||||||
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
|
subtitle="Create access links, apply account profiles, deliver invitations, and see what needs attention."
|
||||||
rail={inviteManagementRail}
|
rail={inviteManagementRail}
|
||||||
actions={
|
|
||||||
<button type="button" className="ghost-button" onClick={() => router.push('/profile/invites?view=user')}>
|
|
||||||
View as user
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<section className="admin-section">
|
<section className="admin-section">
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|||||||
@@ -181,6 +181,107 @@ body {
|
|||||||
align-items: center;
|
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 {
|
.avatar-button {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import './ops-redesign.css'
|
import './ops-redesign.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import HeaderActions from './ui/HeaderActions'
|
|
||||||
import HeaderIdentity from './ui/HeaderIdentity'
|
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
import BrandingLogo from './ui/BrandingLogo'
|
import BrandingLogo from './ui/BrandingLogo'
|
||||||
|
import HeaderActions from './ui/HeaderActions'
|
||||||
|
import HeaderIdentity from './ui/HeaderIdentity'
|
||||||
import SiteStatus from './ui/SiteStatus'
|
import SiteStatus from './ui/SiteStatus'
|
||||||
|
import UserViewBanner from './ui/UserViewBanner'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Magent',
|
title: 'Magent',
|
||||||
@@ -36,6 +37,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<HeaderActions />
|
<HeaderActions />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<UserViewBanner />
|
||||||
<SiteStatus />
|
<SiteStatus />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||||
|
import { useUserViewPreview } from '../../lib/viewMode'
|
||||||
|
|
||||||
type ProfileInfo = {
|
type ProfileInfo = {
|
||||||
username: string
|
username: string
|
||||||
@@ -97,7 +98,7 @@ export default function ProfileInvitesPage() {
|
|||||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||||
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [viewAsUser, setViewAsUser] = useState(false)
|
const viewAsUser = useUserViewPreview()
|
||||||
|
|
||||||
const signupBaseUrl = useMemo(() => {
|
const signupBaseUrl = useMemo(() => {
|
||||||
if (typeof window === 'undefined') return '/signup'
|
if (typeof window === 'undefined') return '/signup'
|
||||||
@@ -136,7 +137,6 @@ export default function ProfileInvitesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewAsUser(new URLSearchParams(window.location.search).get('view') === 'user')
|
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
@@ -309,11 +309,6 @@ export default function ProfileInvitesPage() {
|
|||||||
|
|
||||||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||||
|
|
||||||
const enterUserView = () => {
|
|
||||||
setViewAsUser(true)
|
|
||||||
router.replace('/profile/invites?view=user')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className="card">Loading invite tools...</main>
|
return <main className="card">Loading invite tools...</main>
|
||||||
}
|
}
|
||||||
@@ -330,16 +325,6 @@ export default function ProfileInvitesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-inline-actions">
|
<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')}>
|
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
|
||||||
Back to profile
|
Back to profile
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
|
||||||
|
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
|
||||||
|
|
||||||
export default function HeaderIdentity() {
|
export default function HeaderIdentity() {
|
||||||
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
|
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
|
||||||
const [buildNumber, setBuildNumber] = useState<string | null>(null)
|
const [buildNumber, setBuildNumber] = useState<string | null>(null)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const viewAsUser = useUserViewPreview()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -27,6 +29,9 @@ export default function HeaderIdentity() {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data?.username) {
|
if (data?.username) {
|
||||||
setIdentity({ username: data.username, role: data.role })
|
setIdentity({ username: data.username, role: data.role })
|
||||||
|
if (data.role !== 'admin') {
|
||||||
|
setUserViewPreview(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const siteResponse = await fetch(`${baseUrl}/site/public`)
|
const siteResponse = await fetch(`${baseUrl}/site/public`)
|
||||||
if (siteResponse.ok) {
|
if (siteResponse.ok) {
|
||||||
@@ -50,6 +55,7 @@ export default function HeaderIdentity() {
|
|||||||
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
|
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
|
||||||
const initial = identity.username.slice(0, 1).toUpperCase()
|
const initial = identity.username.slice(0, 1).toUpperCase()
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
|
setUserViewPreview(false)
|
||||||
await logout().catch(() => undefined)
|
await logout().catch(() => undefined)
|
||||||
clearToken()
|
clearToken()
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -58,6 +64,17 @@ export default function HeaderIdentity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="signed-in-menu">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -71,7 +88,10 @@ export default function HeaderIdentity() {
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="signed-in-dropdown">
|
<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">
|
<div className="signed-in-actions">
|
||||||
<a href="/profile" onClick={() => setOpen(false)}>
|
<a href="/profile" onClick={() => setOpen(false)}>
|
||||||
My profile
|
My profile
|
||||||
@@ -92,5 +112,6 @@ export default function HeaderIdentity() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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