feat(app-switcher): централизовать ссылки приложений в portal settings
Публичный GET и admin PUT/UI /admin/apps; каталог и chrome читают URL из store. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -60,6 +60,12 @@ export function migrateSchema(sqlite: Sqlite): void {
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_settings (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
app_switcher_json TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_apps_user ON user_apps(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id);
|
||||
@@ -72,3 +78,4 @@ export function healthCheck(sqlite: Sqlite): void {
|
||||
|
||||
export * from './schema/index.js'
|
||||
export * from './users.js'
|
||||
export * from './settings.js'
|
||||
|
||||
@@ -35,3 +35,10 @@ export const refreshSessions = sqliteTable('refresh_sessions', {
|
||||
revokedAt: text('revoked_at'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
/** Singleton row id = 'main' */
|
||||
export const portalSettings = sqliteTable('portal_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
appSwitcherJson: text('app_switcher_json'),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
defaultAppSwitcherConfig,
|
||||
normalizeAppSwitcherConfig,
|
||||
parseAppSwitcherConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from '@authportal/shared'
|
||||
import type { AppDb } from './index.js'
|
||||
import { portalSettings } from './schema/index.js'
|
||||
|
||||
const SETTINGS_ID = 'main'
|
||||
|
||||
export function getAppSwitcherConfig(db: AppDb): AppSwitcherConfig {
|
||||
const row = db
|
||||
.select()
|
||||
.from(portalSettings)
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.get()
|
||||
if (!row?.appSwitcherJson) return defaultAppSwitcherConfig()
|
||||
try {
|
||||
return parseAppSwitcherConfig(JSON.parse(row.appSwitcherJson))
|
||||
} catch {
|
||||
return defaultAppSwitcherConfig()
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppSwitcherConfig(
|
||||
db: AppDb,
|
||||
config: AppSwitcherConfig,
|
||||
): AppSwitcherConfig {
|
||||
const normalized = normalizeAppSwitcherConfig(config)
|
||||
const now = new Date().toISOString()
|
||||
const json = JSON.stringify(normalized)
|
||||
const existing = db
|
||||
.select()
|
||||
.from(portalSettings)
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.get()
|
||||
if (existing) {
|
||||
db.update(portalSettings)
|
||||
.set({ appSwitcherJson: json, updatedAt: now })
|
||||
.where(eq(portalSettings.id, SETTINGS_ID))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(portalSettings)
|
||||
.values({
|
||||
id: SETTINGS_ID,
|
||||
appSwitcherJson: json,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z } from 'zod'
|
||||
import { APP_IDS, APPS, appIdSchema, type AppId, type AppMeta } from './auth.js'
|
||||
|
||||
export const appSwitcherIconSchema = z.enum([
|
||||
'server',
|
||||
'cloud',
|
||||
'globe',
|
||||
'dashboard',
|
||||
'chart',
|
||||
])
|
||||
|
||||
export const appSwitcherEntrySchema = z.object({
|
||||
id: appIdSchema,
|
||||
name: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string().url(),
|
||||
icon: appSwitcherIconSchema,
|
||||
shortcut: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
sort: z.number().int().optional(),
|
||||
})
|
||||
|
||||
export const appSwitcherConfigSchema = z.object({
|
||||
menuLabel: z.string().min(1),
|
||||
apps: z.array(appSwitcherEntrySchema).min(1),
|
||||
})
|
||||
|
||||
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
|
||||
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
|
||||
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
|
||||
|
||||
const DEFAULT_ICONS: Record<AppId, AppSwitcherIconName> = {
|
||||
cfdm: 'cloud',
|
||||
vps: 'server',
|
||||
bgp: 'globe',
|
||||
}
|
||||
|
||||
/** Seed / fallback when DB is empty. */
|
||||
export function defaultAppSwitcherConfig(): AppSwitcherConfig {
|
||||
return {
|
||||
menuLabel: 'Приложения',
|
||||
apps: APPS.map((app, index) => ({
|
||||
id: app.id,
|
||||
name: app.title,
|
||||
subtitle: app.description,
|
||||
url: app.url,
|
||||
icon: DEFAULT_ICONS[app.id],
|
||||
enabled: true,
|
||||
sort: index,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAppSwitcherConfig(raw: unknown): AppSwitcherConfig {
|
||||
const parsed = appSwitcherConfigSchema.safeParse(raw)
|
||||
if (!parsed.success) return defaultAppSwitcherConfig()
|
||||
return normalizeAppSwitcherConfig(parsed.data)
|
||||
}
|
||||
|
||||
/** Ensure all APP_IDS present; sort; drop unknown. */
|
||||
export function normalizeAppSwitcherConfig(
|
||||
config: AppSwitcherConfig,
|
||||
): AppSwitcherConfig {
|
||||
const byId = new Map(config.apps.map((a) => [a.id, a]))
|
||||
const defaults = defaultAppSwitcherConfig()
|
||||
const apps = APP_IDS.map((id, index) => {
|
||||
const existing = byId.get(id)
|
||||
const fallback = defaults.apps.find((a) => a.id === id)!
|
||||
return {
|
||||
...fallback,
|
||||
...existing,
|
||||
id,
|
||||
sort: existing?.sort ?? index,
|
||||
enabled: existing?.enabled ?? true,
|
||||
icon: existing?.icon ?? fallback.icon,
|
||||
}
|
||||
}).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
|
||||
return {
|
||||
menuLabel: config.menuLabel || 'Приложения',
|
||||
apps,
|
||||
}
|
||||
}
|
||||
|
||||
/** AppMeta list with URLs from switcher store (for /apps + catalog). */
|
||||
export function appsMetaFromSwitcher(config: AppSwitcherConfig): AppMeta[] {
|
||||
const normalized = normalizeAppSwitcherConfig(config)
|
||||
return normalized.apps
|
||||
.filter((a) => a.enabled !== false)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.name,
|
||||
description: a.subtitle ?? APPS.find((x) => x.id === a.id)?.description ?? '',
|
||||
url: a.url,
|
||||
}))
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './contracts/auth.js'
|
||||
export * from './contracts/app-switcher.js'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user