feat(app-switcher): централизовать ссылки приложений в portal settings
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m49s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Публичный GET и admin PUT/UI /admin/apps; каталог и chrome читают URL из store.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 20:59:01 +07:00
co-authored by Cursor
parent 83bfc0355d
commit 0fa4b7adea
18 changed files with 662 additions and 46 deletions
+7
View File
@@ -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'
+7
View File
@@ -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(),
})
+54
View File
@@ -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,
}))
}
+2
View File
@@ -1 +1,3 @@
export * from './contracts/auth.js'
export * from './contracts/app-switcher.js'