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:
2026-08-15 09:39:51 +08:00
co-authored by Claude Opus 5
parent ae29e7b2fc
commit 6ebf21ddd6
5 changed files with 403 additions and 274 deletions
+69
View File
@@ -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>
)
}