feat(console): enable sandbox guest vless qr fallback and hourly guest uuid semantics

This commit is contained in:
Haitao Pan 2026-02-05 23:00:00 +08:00
parent f695a0e937
commit 34622a4afa
4 changed files with 48 additions and 16 deletions

View File

@ -135,6 +135,7 @@ export async function GET(request: NextRequest) {
rawRole === 'readonly' ||
rawRole === 'read_only' ||
String(rawUser.email ?? '').trim().toLowerCase() === 'demo@svc.plus' ||
String(rawUser.email ?? '').trim().toLowerCase() === 'sandbox@svc.plus' ||
isNamedDemo
const normalizedProxyUuid =
typeof rawUser.proxyUuid === 'string' && rawUser.proxyUuid.trim().length > 0

View File

@ -146,7 +146,7 @@ function buildGuestUser(): User {
return {
id: identifier,
uuid: identifier,
email: 'guest@sandbox.local',
email: 'Sandbox@svc.plus',
name: 'Guest user',
username: 'guest',
mfaEnabled: false,
@ -273,6 +273,7 @@ async function fetchSessionUser(): Promise<User | null> {
rawRole === 'readonly' ||
rawRole === 'read_only' ||
normalizedEmail === 'demo@svc.plus' ||
normalizedEmail === 'sandbox@svc.plus' ||
isNamedDemo ||
normalizedGroups.some((value) => value.toLowerCase() === 'readonly role')
const normalizedReadOnly = Boolean(sessionUser.readOnly) || inferredReadOnly

View File

@ -55,9 +55,12 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const username = user?.username ?? '—'
const email = user?.email ?? '—'
const docsUrl = mfaCopy.actions.docsUrl
const isDemoReadOnly = Boolean(user?.isReadOnly && user?.email?.toLowerCase() === 'demo@svc.plus')
const demoUuidExpiresAtText = useMemo(() => {
if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) {
const normalizedEmail = user?.email?.toLowerCase() ?? ''
const isGuestSandboxReadOnly = Boolean(
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus' || normalizedEmail === 'demo@svc.plus'),
)
const guestUuidExpiresAtText = useMemo(() => {
if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) {
return null
}
const date = new Date(user.proxyUuidExpiresAt)
@ -65,7 +68,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
return null
}
return date.toLocaleString()
}, [isDemoReadOnly, user?.proxyUuidExpiresAt])
}, [isGuestSandboxReadOnly, user?.proxyUuidExpiresAt])
const mfaStatusLabel = useMemo(() => {
if (user?.mfaEnabled) {
@ -118,7 +121,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
}, [logout, router])
useEffect(() => {
if (!isDemoReadOnly || !user?.proxyUuidExpiresAt) {
if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) {
return
}
const expiresAt = new Date(user.proxyUuidExpiresAt).getTime()
@ -132,17 +135,17 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
return () => {
window.clearTimeout(timer)
}
}, [isDemoReadOnly, refresh, user?.proxyUuidExpiresAt])
}, [isGuestSandboxReadOnly, refresh, user?.proxyUuidExpiresAt])
return (
<div className="space-y-6 text-[var(--color-text)] transition-colors">
<div>
<p className="text-sm text-[var(--color-text-subtle)] opacity-90">{copy.uuidNote}</p>
{isDemoReadOnly ? (
{isGuestSandboxReadOnly ? (
<p className="mt-2 rounded-[var(--radius-md)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-2 text-xs text-[var(--color-warning-foreground)]">
{language === 'zh'
? `Demo 体验账号为只读模式:可浏览控制台、可使用 VLESS 二维码但不能修改任何配置。UUID 每 1 小时自动刷新${demoUuidExpiresAtText ? `(下次刷新约 ${demoUuidExpiresAtText}` : ''}`
: `Demo account runs in read-only mode: browse safely and use the VLESS QR code, but no configuration changes are allowed. UUID rotates every hour${demoUuidExpiresAtText ? ` (next refresh around ${demoUuidExpiresAtText})` : ''}.`}
? `Guest user演示模式为只读模式:可浏览控制台、可使用 VLESS 二维码但不能修改任何配置。UUID 每 1 小时自动刷新${guestUuidExpiresAtText ? `(下次刷新约 ${guestUuidExpiresAtText}` : ''}`
: `Guest user (demo mode) runs in read-only mode: browse safely and use the VLESS QR code, but no configuration changes are allowed. UUID rotates every hour${guestUuidExpiresAtText ? ` (next refresh around ${guestUuidExpiresAtText})` : ''}.`}
</p>
) : null}
</div>
@ -199,7 +202,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
<p className="mt-3 text-xs text-[var(--color-text-subtle)]">{copy.cards.uuid.description}</p>
</Card>
<VlessQrCard uuid={vlessUuid} copy={copy.cards.vless} />
<VlessQrCard uuid={vlessUuid} copy={copy.cards.vless} allowSandboxFallbackNode={isGuestSandboxReadOnly} />
<Card>
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">{copy.cards.username.label}</p>

View File

@ -54,10 +54,30 @@ export type VlessQrCopy = {
interface VlessQrCardProps {
uuid: string | null | undefined
copy: VlessQrCopy
allowSandboxFallbackNode?: boolean
}
export default function VlessQrCard({ uuid, copy }: VlessQrCardProps) {
const { data: nodes } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
function buildSandboxFallbackNode(): VlessNode {
return {
name: 'Sandbox Node',
address: 'ha-proxy-jp.svc.plus',
port: 1443,
server_name: 'ha-proxy-jp.svc.plus',
transport: 'tcp',
tcp_port: 1443,
xhttp_port: 443,
path: '/split',
mode: 'auto',
flow: 'xtls-rprx-vision',
uri_scheme_tcp:
'vless://${UUID}@${DOMAIN}:1443?encryption=none&flow=${FLOW}&security=tls&sni=${SNI}&fp=${FP}&type=tcp#${TAG}',
uri_scheme_xhttp:
'vless://${UUID}@${DOMAIN}:443?encryption=none&security=tls&sni=${SNI}&fp=${FP}&type=xhttp&path=${PATH}&mode=${MODE}#${TAG}',
}
}
export default function VlessQrCard({ uuid, copy, allowSandboxFallbackNode = false }: VlessQrCardProps) {
const { data: nodes, error: nodesError } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
const [selectedNode, setSelectedNode] = useState<VlessNode | null>(null)
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('tcp')
const [isSelectorOpen, setIsSelectorOpen] = useState(false)
@ -67,7 +87,12 @@ export default function VlessQrCard({ uuid, copy }: VlessQrCardProps) {
const [generationError, setGenerationError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const rawNode = useMemo(() => selectedNode || (nodes && nodes[0]) || undefined, [selectedNode, nodes])
const rawNode = useMemo(() => {
if (selectedNode) return selectedNode
if (nodes && nodes[0]) return nodes[0]
if (allowSandboxFallbackNode && uuid) return buildSandboxFallbackNode()
return undefined
}, [allowSandboxFallbackNode, nodes, selectedNode, uuid])
const effectiveNode = useMemo((): VlessNode | undefined => {
if (!rawNode) return undefined
@ -257,10 +282,12 @@ export default function VlessQrCard({ uuid, copy }: VlessQrCardProps) {
<p className="font-semibold"> UUID </p>
<p className="mt-1">{copy.missingUuid}</p>
</div>
) : !nodes || nodes.length === 0 ? (
) : (!nodes || nodes.length === 0) && !rawNode ? (
<div className="rounded-md border border-[color:var(--color-warning-border)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)]">
<p className="font-semibold"> </p>
<p className="mt-1"> /api/agent-server/v1/nodes </p>
<p className="mt-1">
{nodesError ? `${nodesError.message}` : ''} /api/agent-server/v1/nodes
</p>
</div>
) : !effectiveNode ? (
<div className="rounded-md border border-[color:var(--color-warning-border)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)]">