feat: root assume sandbox banner + actions

This commit is contained in:
Haitao Pan 2026-02-06 18:11:05 +08:00
parent b346e9430a
commit 0252a0d81e

View File

@ -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>
)
}