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. +
+ +
+ ) +}