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
+29
View File
@@ -0,0 +1,29 @@
import { api } from '@/api/client'
import type { ApiResponse } from '@/api/types'
/**
* Operator consoles that live outside Laravel (database admin, ...) but are
* gated by our own admin permissions. The backend registry is
* config/internal_surfaces.php; the surface id must match a key there.
*/
export type InternalSurface = 'db-studio'
export interface InternalSurfaceSession {
kind: 'internal_surface_session'
surface: InternalSurface
label: string
/** single-use entry link — redeem it in the browser before it expires */
url: string
expires_in: number
}
/**
* Exchanges the admin bearer token for a single-use entry link. The link sets
* an HttpOnly session cookie on the surface's own hostname, so it must be
* opened in the browser rather than fetched.
*/
export function openInternalSurface(surface: InternalSurface) {
return api.post<ApiResponse<InternalSurfaceSession>>(
`/api/admin/v1/internal-surfaces/${surface}/session`,
)
}
+3
View File
@@ -31,6 +31,9 @@ export const PERM = {
NOTIFICATION_READ: 'admin:notification:read', NOTIFICATION_READ: 'admin:notification:read',
ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue', ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue',
API_DOCS_READ: 'auth:api-docs:read', API_DOCS_READ: 'auth:api-docs:read',
// internal operator consoles — `auth:` prefixed, so super-admin only
// (the `admin` role is synced to admin:% keys and would otherwise inherit it)
DB_STUDIO_ACCESS: 'auth:db-studio:access',
// role management — only super-admin holds these // role management — only super-admin holds these
ROLE_READ: 'auth:role:read', ROLE_READ: 'auth:role:read',
ROLE_CREATE: 'auth:role:create', ROLE_CREATE: 'auth:role:create',
+7
View File
@@ -4,6 +4,7 @@ import {
BookOpenText, BookOpenText,
Bot, Bot,
ClipboardList, ClipboardList,
Database,
FileText, FileText,
FileJson, FileJson,
Flag, Flag,
@@ -110,6 +111,12 @@ export const NAV_GROUPS: NavGroup[] = [
icon: FileJson, icon: FileJson,
permission: 'auth:api-docs:read', permission: 'auth:api-docs:read',
}, },
{
label: '数据库管理台',
to: '/db-studio',
icon: Database,
permission: 'auth:db-studio:access',
},
], ],
}, },
] ]
+295 -274
View File
@@ -9,38 +9,40 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as ApiDocsRouteImport } from './routes/api-docs'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as ApiDocsRouteImport } from './routes/api-docs'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index' import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users'
import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-reports'
import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales'
import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots'
import { Route as AuthedRolesRouteImport } from './routes/_authed/roles'
import { Route as AuthedSidRouteImport } from './routes/_authed/sid' import { Route as AuthedSidRouteImport } from './routes/_authed/sid'
import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index' import { Route as AuthedRolesRouteImport } from './routes/_authed/roles'
import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending' import { Route as AuthedRobotsRouteImport } from './routes/_authed/robots'
import { Route as AuthedDimzouDocumentsRouteImport } from './routes/_authed/dimzou/documents' import { Route as AuthedLocalesRouteImport } from './routes/_authed/locales'
import { Route as AuthedDimzouPublicationsRouteImport } from './routes/_authed/dimzou/publications' import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-reports'
import { Route as AuthedExcDemandsRouteImport } from './routes/_authed/exc/demands' import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
import { Route as AuthedExcDispatchesRouteImport } from './routes/_authed/exc/dispatches' import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
import { Route as AuthedExcOrdersRouteImport } from './routes/_authed/exc/orders' import { Route as AuthedDbStudioRouteImport } from './routes/_authed/db-studio'
import { Route as AuthedExcProvidersRouteImport } from './routes/_authed/exc/providers' import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
import { Route as AuthedStudioAgentTokensRouteImport } from './routes/_authed/studio/agent-tokens' import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users'
import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles'
import { Route as AuthedStudioCategoryBriefsRouteImport } from './routes/_authed/studio/category-briefs'
import { Route as AuthedStudioRobotAuthorsRouteImport } from './routes/_authed/studio/robot-authors'
import { Route as AuthedStudioSeriesRouteImport } from './routes/_authed/studio/series'
import { Route as AuthedStudioStylePresetsRouteImport } from './routes/_authed/studio/style-presets'
import { Route as AuthedStudioSubtopicsRouteImport } from './routes/_authed/studio/subtopics'
import { Route as AuthedUsersIndexRouteImport } from './routes/_authed/users/index' import { Route as AuthedUsersIndexRouteImport } from './routes/_authed/users/index'
import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index'
import { Route as AuthedStudioSubtopicsRouteImport } from './routes/_authed/studio/subtopics'
import { Route as AuthedStudioStylePresetsRouteImport } from './routes/_authed/studio/style-presets'
import { Route as AuthedStudioSeriesRouteImport } from './routes/_authed/studio/series'
import { Route as AuthedStudioRobotAuthorsRouteImport } from './routes/_authed/studio/robot-authors'
import { Route as AuthedStudioCategoryBriefsRouteImport } from './routes/_authed/studio/category-briefs'
import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles'
import { Route as AuthedStudioAgentTokensRouteImport } from './routes/_authed/studio/agent-tokens'
import { Route as AuthedExcProvidersRouteImport } from './routes/_authed/exc/providers'
import { Route as AuthedExcOrdersRouteImport } from './routes/_authed/exc/orders'
import { Route as AuthedExcDispatchesRouteImport } from './routes/_authed/exc/dispatches'
import { Route as AuthedExcDemandsRouteImport } from './routes/_authed/exc/demands'
import { Route as AuthedDimzouPublicationsRouteImport } from './routes/_authed/dimzou/publications'
import { Route as AuthedDimzouDocumentsRouteImport } from './routes/_authed/dimzou/documents'
import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending'
const AuthedRoute = AuthedRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/_authed', id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiDocsRoute = ApiDocsRouteImport.update({ const ApiDocsRoute = ApiDocsRouteImport.update({
@@ -48,9 +50,8 @@ const ApiDocsRoute = ApiDocsRouteImport.update({
path: '/api-docs', path: '/api-docs',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LoginRoute = LoginRouteImport.update({ const AuthedRoute = AuthedRouteImport.update({
id: '/login', id: '/_authed',
path: '/login',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthedIndexRoute = AuthedIndexRouteImport.update({ const AuthedIndexRoute = AuthedIndexRouteImport.update({
@@ -58,19 +59,29 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedAdminUsersRoute = AuthedAdminUsersRouteImport.update({ const AuthedSidRoute = AuthedSidRouteImport.update({
id: '/admin-users', id: '/sid',
path: '/admin-users', path: '/sid',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({ const AuthedRolesRoute = AuthedRolesRouteImport.update({
id: '/comment-reports', id: '/roles',
path: '/comment-reports', path: '/roles',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedDevicesRoute = AuthedDevicesRouteImport.update({ const AuthedRobotsRoute = AuthedRobotsRouteImport.update({
id: '/devices', id: '/robots',
path: '/devices', path: '/robots',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedLocalesRoute = AuthedLocalesRouteImport.update({
id: '/locales',
path: '/locales',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedEventReportsRoute = AuthedEventReportsRouteImport.update({
id: '/event-reports',
path: '/event-reports',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedDimzouTranslationsRoute = const AuthedDimzouTranslationsRoute =
@@ -79,29 +90,29 @@ const AuthedDimzouTranslationsRoute =
path: '/dimzou-translations', path: '/dimzou-translations',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedEventReportsRoute = AuthedEventReportsRouteImport.update({ const AuthedDevicesRoute = AuthedDevicesRouteImport.update({
id: '/event-reports', id: '/devices',
path: '/event-reports', path: '/devices',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedLocalesRoute = AuthedLocalesRouteImport.update({ const AuthedDbStudioRoute = AuthedDbStudioRouteImport.update({
id: '/locales', id: '/db-studio',
path: '/locales', path: '/db-studio',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedRobotsRoute = AuthedRobotsRouteImport.update({ const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({
id: '/robots', id: '/comment-reports',
path: '/robots', path: '/comment-reports',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedRolesRoute = AuthedRolesRouteImport.update({ const AuthedAdminUsersRoute = AuthedAdminUsersRouteImport.update({
id: '/roles', id: '/admin-users',
path: '/roles', path: '/admin-users',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedSidRoute = AuthedSidRouteImport.update({ const AuthedUsersIndexRoute = AuthedUsersIndexRouteImport.update({
id: '/sid', id: '/users/',
path: '/sid', path: '/users/',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({ const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({
@@ -109,67 +120,9 @@ const AuthedCategoriesIndexRoute = AuthedCategoriesIndexRouteImport.update({
path: '/categories/', path: '/categories/',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({ const AuthedStudioSubtopicsRoute = AuthedStudioSubtopicsRouteImport.update({
id: '/categories/pending', id: '/studio/subtopics',
path: '/categories/pending', path: '/studio/subtopics',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouDocumentsRoute = AuthedDimzouDocumentsRouteImport.update({
id: '/dimzou/documents',
path: '/dimzou/documents',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouPublicationsRoute =
AuthedDimzouPublicationsRouteImport.update({
id: '/dimzou/publications',
path: '/dimzou/publications',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDemandsRoute = AuthedExcDemandsRouteImport.update({
id: '/exc/demands',
path: '/exc/demands',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDispatchesRoute = AuthedExcDispatchesRouteImport.update({
id: '/exc/dispatches',
path: '/exc/dispatches',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcOrdersRoute = AuthedExcOrdersRouteImport.update({
id: '/exc/orders',
path: '/exc/orders',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcProvidersRoute = AuthedExcProvidersRouteImport.update({
id: '/exc/providers',
path: '/exc/providers',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioAgentTokensRoute = AuthedStudioAgentTokensRouteImport.update({
id: '/studio/agent-tokens',
path: '/studio/agent-tokens',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioArticlesRoute = AuthedStudioArticlesRouteImport.update({
id: '/studio/articles',
path: '/studio/articles',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioCategoryBriefsRoute =
AuthedStudioCategoryBriefsRouteImport.update({
id: '/studio/category-briefs',
path: '/studio/category-briefs',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioRobotAuthorsRoute =
AuthedStudioRobotAuthorsRouteImport.update({
id: '/studio/robot-authors',
path: '/studio/robot-authors',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioSeriesRoute = AuthedStudioSeriesRouteImport.update({
id: '/studio/series',
path: '/studio/series',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedStudioStylePresetsRoute = const AuthedStudioStylePresetsRoute =
@@ -178,14 +131,67 @@ const AuthedStudioStylePresetsRoute =
path: '/studio/style-presets', path: '/studio/style-presets',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedStudioSubtopicsRoute = AuthedStudioSubtopicsRouteImport.update({ const AuthedStudioSeriesRoute = AuthedStudioSeriesRouteImport.update({
id: '/studio/subtopics', id: '/studio/series',
path: '/studio/subtopics', path: '/studio/series',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedUsersIndexRoute = AuthedUsersIndexRouteImport.update({ const AuthedStudioRobotAuthorsRoute =
id: '/users/', AuthedStudioRobotAuthorsRouteImport.update({
path: '/users/', id: '/studio/robot-authors',
path: '/studio/robot-authors',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioCategoryBriefsRoute =
AuthedStudioCategoryBriefsRouteImport.update({
id: '/studio/category-briefs',
path: '/studio/category-briefs',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioArticlesRoute = AuthedStudioArticlesRouteImport.update({
id: '/studio/articles',
path: '/studio/articles',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedStudioAgentTokensRoute = AuthedStudioAgentTokensRouteImport.update({
id: '/studio/agent-tokens',
path: '/studio/agent-tokens',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcProvidersRoute = AuthedExcProvidersRouteImport.update({
id: '/exc/providers',
path: '/exc/providers',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcOrdersRoute = AuthedExcOrdersRouteImport.update({
id: '/exc/orders',
path: '/exc/orders',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDispatchesRoute = AuthedExcDispatchesRouteImport.update({
id: '/exc/dispatches',
path: '/exc/dispatches',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDemandsRoute = AuthedExcDemandsRouteImport.update({
id: '/exc/demands',
path: '/exc/demands',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouPublicationsRoute =
AuthedDimzouPublicationsRouteImport.update({
id: '/dimzou/publications',
path: '/dimzou/publications',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedDimzouDocumentsRoute = AuthedDimzouDocumentsRouteImport.update({
id: '/dimzou/documents',
path: '/dimzou/documents',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({
id: '/categories/pending',
path: '/categories/pending',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
@@ -195,6 +201,7 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/admin-users': typeof AuthedAdminUsersRoute '/admin-users': typeof AuthedAdminUsersRoute
'/comment-reports': typeof AuthedCommentReportsRoute '/comment-reports': typeof AuthedCommentReportsRoute
'/db-studio': typeof AuthedDbStudioRoute
'/devices': typeof AuthedDevicesRoute '/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute '/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/event-reports': typeof AuthedEventReportsRoute '/event-reports': typeof AuthedEventReportsRoute
@@ -224,6 +231,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/admin-users': typeof AuthedAdminUsersRoute '/admin-users': typeof AuthedAdminUsersRoute
'/comment-reports': typeof AuthedCommentReportsRoute '/comment-reports': typeof AuthedCommentReportsRoute
'/db-studio': typeof AuthedDbStudioRoute
'/devices': typeof AuthedDevicesRoute '/devices': typeof AuthedDevicesRoute
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute '/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/event-reports': typeof AuthedEventReportsRoute '/event-reports': typeof AuthedEventReportsRoute
@@ -256,6 +264,7 @@ export interface FileRoutesById {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/_authed/admin-users': typeof AuthedAdminUsersRoute '/_authed/admin-users': typeof AuthedAdminUsersRoute
'/_authed/comment-reports': typeof AuthedCommentReportsRoute '/_authed/comment-reports': typeof AuthedCommentReportsRoute
'/_authed/db-studio': typeof AuthedDbStudioRoute
'/_authed/devices': typeof AuthedDevicesRoute '/_authed/devices': typeof AuthedDevicesRoute
'/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute '/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute
'/_authed/event-reports': typeof AuthedEventReportsRoute '/_authed/event-reports': typeof AuthedEventReportsRoute
@@ -289,6 +298,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/admin-users' | '/admin-users'
| '/comment-reports' | '/comment-reports'
| '/db-studio'
| '/devices' | '/devices'
| '/dimzou-translations' | '/dimzou-translations'
| '/event-reports' | '/event-reports'
@@ -318,6 +328,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/admin-users' | '/admin-users'
| '/comment-reports' | '/comment-reports'
| '/db-studio'
| '/devices' | '/devices'
| '/dimzou-translations' | '/dimzou-translations'
| '/event-reports' | '/event-reports'
@@ -349,6 +360,7 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/_authed/admin-users' | '/_authed/admin-users'
| '/_authed/comment-reports' | '/_authed/comment-reports'
| '/_authed/db-studio'
| '/_authed/devices' | '/_authed/devices'
| '/_authed/dimzou-translations' | '/_authed/dimzou-translations'
| '/_authed/event-reports' | '/_authed/event-reports'
@@ -383,11 +395,11 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/_authed': { '/login': {
id: '/_authed' id: '/login'
path: '' path: '/login'
fullPath: '/' fullPath: '/login'
preLoaderRoute: typeof AuthedRouteImport preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api-docs': { '/api-docs': {
@@ -397,11 +409,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiDocsRouteImport preLoaderRoute: typeof ApiDocsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/login': { '/_authed': {
id: '/login' id: '/_authed'
path: '/login' path: ''
fullPath: '/login' fullPath: '/'
preLoaderRoute: typeof LoginRouteImport preLoaderRoute: typeof AuthedRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/_authed/': { '/_authed/': {
@@ -411,53 +423,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedIndexRouteImport preLoaderRoute: typeof AuthedIndexRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/admin-users': { '/_authed/sid': {
id: '/_authed/admin-users' id: '/_authed/sid'
path: '/admin-users' path: '/sid'
fullPath: '/admin-users' fullPath: '/sid'
preLoaderRoute: typeof AuthedAdminUsersRouteImport preLoaderRoute: typeof AuthedSidRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/comment-reports': {
id: '/_authed/comment-reports'
path: '/comment-reports'
fullPath: '/comment-reports'
preLoaderRoute: typeof AuthedCommentReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/devices': {
id: '/_authed/devices'
path: '/devices'
fullPath: '/devices'
preLoaderRoute: typeof AuthedDevicesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou-translations': {
id: '/_authed/dimzou-translations'
path: '/dimzou-translations'
fullPath: '/dimzou-translations'
preLoaderRoute: typeof AuthedDimzouTranslationsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/event-reports': {
id: '/_authed/event-reports'
path: '/event-reports'
fullPath: '/event-reports'
preLoaderRoute: typeof AuthedEventReportsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/locales': {
id: '/_authed/locales'
path: '/locales'
fullPath: '/locales'
preLoaderRoute: typeof AuthedLocalesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/robots': {
id: '/_authed/robots'
path: '/robots'
fullPath: '/robots'
preLoaderRoute: typeof AuthedRobotsRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/roles': { '/_authed/roles': {
@@ -467,116 +437,60 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedRolesRouteImport preLoaderRoute: typeof AuthedRolesRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/sid': { '/_authed/robots': {
id: '/_authed/sid' id: '/_authed/robots'
path: '/sid' path: '/robots'
fullPath: '/sid' fullPath: '/robots'
preLoaderRoute: typeof AuthedSidRouteImport preLoaderRoute: typeof AuthedRobotsRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/categories/': { '/_authed/locales': {
id: '/_authed/categories/' id: '/_authed/locales'
path: '/categories' path: '/locales'
fullPath: '/categories/' fullPath: '/locales'
preLoaderRoute: typeof AuthedCategoriesIndexRouteImport preLoaderRoute: typeof AuthedLocalesRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/categories/pending': { '/_authed/event-reports': {
id: '/_authed/categories/pending' id: '/_authed/event-reports'
path: '/categories/pending' path: '/event-reports'
fullPath: '/categories/pending' fullPath: '/event-reports'
preLoaderRoute: typeof AuthedCategoriesPendingRouteImport preLoaderRoute: typeof AuthedEventReportsRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/dimzou/documents': { '/_authed/dimzou-translations': {
id: '/_authed/dimzou/documents' id: '/_authed/dimzou-translations'
path: '/dimzou/documents' path: '/dimzou-translations'
fullPath: '/dimzou/documents' fullPath: '/dimzou-translations'
preLoaderRoute: typeof AuthedDimzouDocumentsRouteImport preLoaderRoute: typeof AuthedDimzouTranslationsRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/dimzou/publications': { '/_authed/devices': {
id: '/_authed/dimzou/publications' id: '/_authed/devices'
path: '/dimzou/publications' path: '/devices'
fullPath: '/dimzou/publications' fullPath: '/devices'
preLoaderRoute: typeof AuthedDimzouPublicationsRouteImport preLoaderRoute: typeof AuthedDevicesRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/exc/demands': { '/_authed/db-studio': {
id: '/_authed/exc/demands' id: '/_authed/db-studio'
path: '/exc/demands' path: '/db-studio'
fullPath: '/exc/demands' fullPath: '/db-studio'
preLoaderRoute: typeof AuthedExcDemandsRouteImport preLoaderRoute: typeof AuthedDbStudioRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/exc/dispatches': { '/_authed/comment-reports': {
id: '/_authed/exc/dispatches' id: '/_authed/comment-reports'
path: '/exc/dispatches' path: '/comment-reports'
fullPath: '/exc/dispatches' fullPath: '/comment-reports'
preLoaderRoute: typeof AuthedExcDispatchesRouteImport preLoaderRoute: typeof AuthedCommentReportsRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/exc/orders': { '/_authed/admin-users': {
id: '/_authed/exc/orders' id: '/_authed/admin-users'
path: '/exc/orders' path: '/admin-users'
fullPath: '/exc/orders' fullPath: '/admin-users'
preLoaderRoute: typeof AuthedExcOrdersRouteImport preLoaderRoute: typeof AuthedAdminUsersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/providers': {
id: '/_authed/exc/providers'
path: '/exc/providers'
fullPath: '/exc/providers'
preLoaderRoute: typeof AuthedExcProvidersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/agent-tokens': {
id: '/_authed/studio/agent-tokens'
path: '/studio/agent-tokens'
fullPath: '/studio/agent-tokens'
preLoaderRoute: typeof AuthedStudioAgentTokensRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/articles': {
id: '/_authed/studio/articles'
path: '/studio/articles'
fullPath: '/studio/articles'
preLoaderRoute: typeof AuthedStudioArticlesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/category-briefs': {
id: '/_authed/studio/category-briefs'
path: '/studio/category-briefs'
fullPath: '/studio/category-briefs'
preLoaderRoute: typeof AuthedStudioCategoryBriefsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/robot-authors': {
id: '/_authed/studio/robot-authors'
path: '/studio/robot-authors'
fullPath: '/studio/robot-authors'
preLoaderRoute: typeof AuthedStudioRobotAuthorsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/series': {
id: '/_authed/studio/series'
path: '/studio/series'
fullPath: '/studio/series'
preLoaderRoute: typeof AuthedStudioSeriesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/style-presets': {
id: '/_authed/studio/style-presets'
path: '/studio/style-presets'
fullPath: '/studio/style-presets'
preLoaderRoute: typeof AuthedStudioStylePresetsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/subtopics': {
id: '/_authed/studio/subtopics'
path: '/studio/subtopics'
fullPath: '/studio/subtopics'
preLoaderRoute: typeof AuthedStudioSubtopicsRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/users/': { '/_authed/users/': {
@@ -586,12 +500,118 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedUsersIndexRouteImport preLoaderRoute: typeof AuthedUsersIndexRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/categories/': {
id: '/_authed/categories/'
path: '/categories'
fullPath: '/categories/'
preLoaderRoute: typeof AuthedCategoriesIndexRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/subtopics': {
id: '/_authed/studio/subtopics'
path: '/studio/subtopics'
fullPath: '/studio/subtopics'
preLoaderRoute: typeof AuthedStudioSubtopicsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/style-presets': {
id: '/_authed/studio/style-presets'
path: '/studio/style-presets'
fullPath: '/studio/style-presets'
preLoaderRoute: typeof AuthedStudioStylePresetsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/series': {
id: '/_authed/studio/series'
path: '/studio/series'
fullPath: '/studio/series'
preLoaderRoute: typeof AuthedStudioSeriesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/robot-authors': {
id: '/_authed/studio/robot-authors'
path: '/studio/robot-authors'
fullPath: '/studio/robot-authors'
preLoaderRoute: typeof AuthedStudioRobotAuthorsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/category-briefs': {
id: '/_authed/studio/category-briefs'
path: '/studio/category-briefs'
fullPath: '/studio/category-briefs'
preLoaderRoute: typeof AuthedStudioCategoryBriefsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/articles': {
id: '/_authed/studio/articles'
path: '/studio/articles'
fullPath: '/studio/articles'
preLoaderRoute: typeof AuthedStudioArticlesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/studio/agent-tokens': {
id: '/_authed/studio/agent-tokens'
path: '/studio/agent-tokens'
fullPath: '/studio/agent-tokens'
preLoaderRoute: typeof AuthedStudioAgentTokensRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/providers': {
id: '/_authed/exc/providers'
path: '/exc/providers'
fullPath: '/exc/providers'
preLoaderRoute: typeof AuthedExcProvidersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/orders': {
id: '/_authed/exc/orders'
path: '/exc/orders'
fullPath: '/exc/orders'
preLoaderRoute: typeof AuthedExcOrdersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/dispatches': {
id: '/_authed/exc/dispatches'
path: '/exc/dispatches'
fullPath: '/exc/dispatches'
preLoaderRoute: typeof AuthedExcDispatchesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/demands': {
id: '/_authed/exc/demands'
path: '/exc/demands'
fullPath: '/exc/demands'
preLoaderRoute: typeof AuthedExcDemandsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou/publications': {
id: '/_authed/dimzou/publications'
path: '/dimzou/publications'
fullPath: '/dimzou/publications'
preLoaderRoute: typeof AuthedDimzouPublicationsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/dimzou/documents': {
id: '/_authed/dimzou/documents'
path: '/dimzou/documents'
fullPath: '/dimzou/documents'
preLoaderRoute: typeof AuthedDimzouDocumentsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/categories/pending': {
id: '/_authed/categories/pending'
path: '/categories/pending'
fullPath: '/categories/pending'
preLoaderRoute: typeof AuthedCategoriesPendingRouteImport
parentRoute: typeof AuthedRoute
}
} }
} }
interface AuthedRouteChildren { interface AuthedRouteChildren {
AuthedAdminUsersRoute: typeof AuthedAdminUsersRoute AuthedAdminUsersRoute: typeof AuthedAdminUsersRoute
AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute
AuthedDbStudioRoute: typeof AuthedDbStudioRoute
AuthedDevicesRoute: typeof AuthedDevicesRoute AuthedDevicesRoute: typeof AuthedDevicesRoute
AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute
AuthedEventReportsRoute: typeof AuthedEventReportsRoute AuthedEventReportsRoute: typeof AuthedEventReportsRoute
@@ -621,6 +641,7 @@ interface AuthedRouteChildren {
const AuthedRouteChildren: AuthedRouteChildren = { const AuthedRouteChildren: AuthedRouteChildren = {
AuthedAdminUsersRoute: AuthedAdminUsersRoute, AuthedAdminUsersRoute: AuthedAdminUsersRoute,
AuthedCommentReportsRoute: AuthedCommentReportsRoute, AuthedCommentReportsRoute: AuthedCommentReportsRoute,
AuthedDbStudioRoute: AuthedDbStudioRoute,
AuthedDevicesRoute: AuthedDevicesRoute, AuthedDevicesRoute: AuthedDevicesRoute,
AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute, AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute,
AuthedEventReportsRoute: AuthedEventReportsRoute, AuthedEventReportsRoute: AuthedEventReportsRoute,
+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>
)
}