feat(db-studio): add gated entry to the database console
Adds 开发工具 → 数据库管理台, which exchanges the admin bearer token for a single-use entry link (POST /api/admin/v1/internal-surfaces/db-studio/session) and opens it in a new tab. The link sets an HttpOnly session cookie on the console's own hostname, so it has to be opened by the browser rather than fetched — the nginx gate in front of the console then re-checks the permission on every request. The tab is opened synchronously inside the click handler, before the request resolves, or the popup blocker kills it; `noopener` is unusable there since it makes window.open return null, so the opener is detached manually. Gated on auth:db-studio:access. That key is `auth:`-prefixed rather than `admin:` on purpose — PermissionSeeder syncs the admin role to every `admin:%` key, which would grant database-owner SQL access to every admin account. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { openInternalSurface } from '@/api/modules/internal-surfaces'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { handleApiError } from '@/lib/errors'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export const Route = createFileRoute('/_authed/db-studio')({
|
||||
beforeLoad: () => requirePermission(PERM.DB_STUDIO_ACCESS),
|
||||
component: DbStudioPage,
|
||||
})
|
||||
|
||||
function DbStudioPage() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const open = async () => {
|
||||
setBusy(true)
|
||||
|
||||
// The tab must be opened synchronously inside the click handler, or the
|
||||
// popup blocker kills it once the request resolves. `noopener` is not
|
||||
// usable here (it makes window.open return null), so detach manually.
|
||||
const tab = window.open('about:blank', '_blank')
|
||||
|
||||
try {
|
||||
const res = await openInternalSurface('db-studio')
|
||||
if (tab) {
|
||||
tab.opener = null
|
||||
tab.location.replace(res.data.url)
|
||||
} else {
|
||||
// popup blocked — fall back to navigating this tab
|
||||
window.location.assign(res.data.url)
|
||||
}
|
||||
} catch (error) {
|
||||
tab?.close()
|
||||
handleApiError(error)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="数据库管理台"
|
||||
description="在浏览器中查询与编辑生产数据库(Supabase Studio)"
|
||||
/>
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">打开管理台</CardTitle>
|
||||
<CardDescription>
|
||||
将在新标签页中打开。入口链接一次性有效、60 秒内过期,会话约 30
|
||||
分钟后失效,重新打开即可。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
管理台以数据库属主身份执行 SQL,操作不可撤销,且不会经过应用层的校验与审计。
|
||||
请优先使用后台既有功能,仅在排查问题时使用本工具。
|
||||
</p>
|
||||
<Button onClick={open} disabled={busy}>
|
||||
{busy ? '正在打开…' : '打开数据库管理台'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user