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:
@@ -3,17 +3,20 @@ import { hash } from '@node-rs/argon2'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getAppSwitcherConfig,
|
||||
getUserApps,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
getUserPermissions,
|
||||
listUsers,
|
||||
setAppSwitcherConfig,
|
||||
setUserAccess,
|
||||
updateUser,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
APP_IDS,
|
||||
allPermissionKeys,
|
||||
appSwitcherConfigSchema,
|
||||
createUserRequestSchema,
|
||||
patchUserRequestSchema,
|
||||
putUserAccessRequestSchema,
|
||||
@@ -205,4 +208,18 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
return mapUser(app.db, getUserById(app.db, request.params.id)!)
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/v1/admin/app-switcher', async () =>
|
||||
getAppSwitcherConfig(app.db),
|
||||
)
|
||||
|
||||
app.put('/api/v1/admin/app-switcher', async (request, reply) => {
|
||||
const parsed = appSwitcherConfigSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
return setAppSwitcherConfig(app.db, parsed.data)
|
||||
})
|
||||
}
|
||||
|
||||
+17
-10
@@ -1,19 +1,20 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { hash, verify } from '@node-rs/argon2'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
appsMetaFromSwitcher,
|
||||
loginRequestSchema,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
createRefreshSession,
|
||||
getAppSwitcherConfig,
|
||||
getUserApps,
|
||||
getUserByEmail,
|
||||
getUserPermissions,
|
||||
revokeRefreshSession,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
APPS,
|
||||
PERMISSION_CATALOG,
|
||||
loginRequestSchema,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import { requireAuth, toMe } from '../plugins/auth-guards.js'
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token'
|
||||
@@ -104,6 +105,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
|
||||
app.get('/api/v1/app-switcher', async () => getAppSwitcherConfig(app.db))
|
||||
|
||||
app.get(
|
||||
'/api/v1/auth/me',
|
||||
{ onRequest: requireAuth },
|
||||
@@ -123,9 +127,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get(
|
||||
'/api/v1/catalog',
|
||||
{ onRequest: requireAuth },
|
||||
async () => ({
|
||||
apps: APPS,
|
||||
permissions: PERMISSION_CATALOG,
|
||||
}),
|
||||
async () => {
|
||||
const switcher = getAppSwitcherConfig(app.db)
|
||||
return {
|
||||
apps: appsMetaFromSwitcher(switcher),
|
||||
permissions: PERMISSION_CATALOG,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
|
||||
describe('app-switcher API', () => {
|
||||
it('GET /api/v1/app-switcher is public and returns defaults', async () => {
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_PASSWORD: 'admin',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
const app = await buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
||||
expect(body.menuLabel).toBeTruthy()
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual(['bgp', 'cfdm', 'vps'])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('PUT /api/v1/admin/app-switcher requires admin and persists', async () => {
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: '[email protected]',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
const app = await buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
payload: { menuLabel: 'Apps', apps: [] },
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: '[email protected]', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const getBefore = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const before = getBefore.json() as {
|
||||
menuLabel: string
|
||||
apps: {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
const updated = {
|
||||
menuLabel: 'Сервисы',
|
||||
apps: before.apps.map((a) =>
|
||||
a.id === 'cfdm'
|
||||
? { ...a, url: 'https://cfdm.example.test', name: 'CFDM Test' }
|
||||
: a,
|
||||
),
|
||||
}
|
||||
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: updated,
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect(put.json()).toMatchObject({ menuLabel: 'Сервисы' })
|
||||
|
||||
const getAfter = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const after = getAfter.json() as typeof before
|
||||
expect(after.menuLabel).toBe('Сервисы')
|
||||
expect(after.apps.find((a) => a.id === 'cfdm')?.url).toBe(
|
||||
'https://cfdm.example.test',
|
||||
)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user