feat: root assume sandbox banner + actions
This commit is contained in:
parent
b346e9430a
commit
0252a0d81e
@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ChevronLeft, ChevronRight, Menu } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useUserStore } from '@lib/userStore'
|
||||
import type { UserRole } from '@lib/userStore'
|
||||
@ -48,6 +50,7 @@ function resolveAccountInitial(input?: string | null) {
|
||||
|
||||
export default function Header({ onMenu, onCollapse, isCollapsed }: HeaderProps) {
|
||||
const { language } = useLanguage()
|
||||
const router = useRouter()
|
||||
const user = useUserStore((state) => state.user)
|
||||
const isLoading = useUserStore((state) => state.isLoading)
|
||||
const role: UserRole = user?.role ?? 'guest'
|
||||
@ -59,8 +62,104 @@ export default function Header({ onMenu, onCollapse, isCollapsed }: HeaderProps)
|
||||
? 'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-70'
|
||||
: badge.className
|
||||
|
||||
const isRoot = useMemo(() => {
|
||||
const email = user?.email?.trim().toLowerCase() ?? ''
|
||||
return email === 'admin@svc.plus' && role === 'admin'
|
||||
}, [role, user?.email])
|
||||
|
||||
const [assumeStatus, setAssumeStatus] = useState<{ isAssuming: boolean; target?: string }>({
|
||||
isAssuming: false,
|
||||
})
|
||||
const [assumeBusy, setAssumeBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sandbox/assume/status', { method: 'GET', cache: 'no-store' })
|
||||
const payload = (await res.json().catch(() => null)) as any
|
||||
if (cancelled) return
|
||||
setAssumeStatus({
|
||||
isAssuming: Boolean(payload?.isAssuming),
|
||||
target: typeof payload?.target === 'string' ? payload.target : undefined,
|
||||
})
|
||||
} catch {
|
||||
if (cancelled) return
|
||||
setAssumeStatus({ isAssuming: false })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAssumeSandbox = async () => {
|
||||
if (!isRoot || assumeBusy) return
|
||||
try {
|
||||
setAssumeBusy(true)
|
||||
const res = await fetch('/api/sandbox/assume', { method: 'POST', cache: 'no-store', credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as any
|
||||
throw new Error((payload && (payload.message || payload.error)) || `Assume failed (${res.status})`)
|
||||
}
|
||||
router.refresh()
|
||||
// Ensure server-rendered parts reflect the new cookie immediately.
|
||||
window.location.reload()
|
||||
} finally {
|
||||
setAssumeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevertAssume = async () => {
|
||||
if (assumeBusy) return
|
||||
try {
|
||||
setAssumeBusy(true)
|
||||
const res = await fetch('/api/sandbox/assume/revert', { method: 'POST', cache: 'no-store', credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as any
|
||||
throw new Error((payload && (payload.message || payload.error)) || `Revert failed (${res.status})`)
|
||||
}
|
||||
router.refresh()
|
||||
window.location.reload()
|
||||
} finally {
|
||||
setAssumeBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex items-center justify-between border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-translucent)] px-4 py-3 text-[var(--color-text)] shadow-[var(--shadow-sm)] backdrop-blur transition-colors md:px-6">
|
||||
<header className="sticky top-0 z-30 border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-translucent)] text-[var(--color-text)] shadow-[var(--shadow-sm)] backdrop-blur transition-colors">
|
||||
{assumeStatus.isAssuming ? (
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2 text-xs md:px-6">
|
||||
<div className="rounded-full border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-1 text-[var(--color-warning-foreground)]">
|
||||
{language === 'zh'
|
||||
? `当前处于 Assume: ${assumeStatus.target || 'sandbox@svc.plus'}(只读视角)`
|
||||
: `Assuming: ${assumeStatus.target || 'sandbox@svc.plus'} (read-only view)`}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevertAssume()}
|
||||
disabled={assumeBusy}
|
||||
className="rounded-full border border-[color:var(--color-warning-muted)] px-3 py-1 text-[var(--color-warning-foreground)] transition-colors hover:bg-[var(--color-warning-muted)] disabled:opacity-60"
|
||||
>
|
||||
{assumeBusy ? (language === 'zh' ? '处理中…' : 'Working…') : language === 'zh' ? '退出 Sandbox' : 'Exit Sandbox'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2 text-xs md:px-6">
|
||||
{isRoot ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAssumeSandbox()}
|
||||
disabled={assumeBusy || isLoading}
|
||||
className="rounded-full border border-[color:var(--color-primary-border)] px-3 py-1 text-[var(--color-primary)] transition-colors hover:bg-[var(--color-primary-muted)] disabled:opacity-60"
|
||||
>
|
||||
{assumeBusy ? (language === 'zh' ? '处理中…' : 'Working…') : language === 'zh' ? '切换到 Sandbox' : 'Assume Sandbox'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-3 md:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
@ -102,6 +201,7 @@ export default function Header({ onMenu, onCollapse, isCollapsed }: HeaderProps)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user