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 {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
getAppSwitcherConfig,
|
||||||
getUserApps,
|
getUserApps,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserPermissions,
|
getUserPermissions,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
setAppSwitcherConfig,
|
||||||
setUserAccess,
|
setUserAccess,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import {
|
import {
|
||||||
APP_IDS,
|
APP_IDS,
|
||||||
allPermissionKeys,
|
allPermissionKeys,
|
||||||
|
appSwitcherConfigSchema,
|
||||||
createUserRequestSchema,
|
createUserRequestSchema,
|
||||||
patchUserRequestSchema,
|
patchUserRequestSchema,
|
||||||
putUserAccessRequestSchema,
|
putUserAccessRequestSchema,
|
||||||
@@ -205,4 +208,18 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return mapUser(app.db, getUserById(app.db, request.params.id)!)
|
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 type { FastifyInstance } from 'fastify'
|
||||||
import { hash, verify } from '@node-rs/argon2'
|
import { hash, verify } from '@node-rs/argon2'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
PERMISSION_CATALOG,
|
||||||
|
appsMetaFromSwitcher,
|
||||||
|
loginRequestSchema,
|
||||||
|
type LoginResponse,
|
||||||
|
} from '@authportal/shared'
|
||||||
import {
|
import {
|
||||||
createRefreshSession,
|
createRefreshSession,
|
||||||
|
getAppSwitcherConfig,
|
||||||
getUserApps,
|
getUserApps,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getUserPermissions,
|
getUserPermissions,
|
||||||
revokeRefreshSession,
|
revokeRefreshSession,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import {
|
|
||||||
APPS,
|
|
||||||
PERMISSION_CATALOG,
|
|
||||||
loginRequestSchema,
|
|
||||||
type LoginResponse,
|
|
||||||
} from '@authportal/shared'
|
|
||||||
import { requireAuth, toMe } from '../plugins/auth-guards.js'
|
import { requireAuth, toMe } from '../plugins/auth-guards.js'
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token'
|
const REFRESH_COOKIE = 'refresh_token'
|
||||||
@@ -104,6 +105,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return { ok: true }
|
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(
|
app.get(
|
||||||
'/api/v1/auth/me',
|
'/api/v1/auth/me',
|
||||||
{ onRequest: requireAuth },
|
{ onRequest: requireAuth },
|
||||||
@@ -123,9 +127,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.get(
|
app.get(
|
||||||
'/api/v1/catalog',
|
'/api/v1/catalog',
|
||||||
{ onRequest: requireAuth },
|
{ onRequest: requireAuth },
|
||||||
async () => ({
|
async () => {
|
||||||
apps: APPS,
|
const switcher = getAppSwitcherConfig(app.db)
|
||||||
permissions: PERMISSION_CATALOG,
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { LayoutGridIcon, UsersIcon } from 'lucide-react'
|
import { LayoutGridIcon, UsersIcon, AppWindowIcon } from 'lucide-react'
|
||||||
import { AppSwitcher } from '@/components/app-switcher'
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
import { meQueryOptions } from '@/queries/auth'
|
import { meQueryOptions } from '@/queries/auth'
|
||||||
import {
|
import {
|
||||||
@@ -57,13 +57,23 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip="Пользователи"
|
tooltip="Пользователи"
|
||||||
isActive={isActive(pathname, '/admin', false)}
|
isActive={isActive(pathname, '/admin', false) && !pathname.startsWith('/admin/apps')}
|
||||||
render={<Link to="/admin" />}
|
render={<Link to="/admin" />}
|
||||||
>
|
>
|
||||||
<UsersIcon className="size-4" />
|
<UsersIcon className="size-4" />
|
||||||
<span>Пользователи</span>
|
<span>Пользователи</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip="Приложения"
|
||||||
|
isActive={isActive(pathname, '/admin/apps', false)}
|
||||||
|
render={<Link to="/admin/apps" />}
|
||||||
|
>
|
||||||
|
<AppWindowIcon className="size-4" />
|
||||||
|
<span>Ссылки приложений</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APPS } from '@authportal/shared'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
CloudIcon,
|
CloudIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ChartColumnIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import type { AppSwitcherIconName } from '@authportal/shared'
|
||||||
|
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -24,18 +28,24 @@ const PORTAL = {
|
|||||||
id: 'portal',
|
id: 'portal',
|
||||||
name: 'Auth Portal',
|
name: 'Auth Portal',
|
||||||
subtitle: 'shnt.top',
|
subtitle: 'shnt.top',
|
||||||
url: '/',
|
|
||||||
icon: KeyRoundIcon,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const APP_ICONS = {
|
const ICON_MAP: Record<
|
||||||
cfdm: CloudIcon,
|
AppSwitcherIconName,
|
||||||
vps: ServerIcon,
|
React.ComponentType<{ className?: string }>
|
||||||
bgp: NetworkIcon,
|
> = {
|
||||||
} as const
|
cloud: CloudIcon,
|
||||||
|
server: ServerIcon,
|
||||||
|
globe: NetworkIcon,
|
||||||
|
dashboard: LayoutDashboardIcon,
|
||||||
|
chart: ChartColumnIcon,
|
||||||
|
}
|
||||||
|
|
||||||
export function AppSwitcher() {
|
export function AppSwitcher() {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const { data } = useQuery(appSwitcherQueryOptions)
|
||||||
|
const menuLabel = data?.menuLabel ?? 'Приложения'
|
||||||
|
const apps = (data?.apps ?? []).filter((a) => a.enabled !== false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -67,15 +77,15 @@ export function AppSwitcher() {
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||||
Приложения
|
{menuLabel}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem disabled>
|
||||||
<KeyRoundIcon />
|
<KeyRoundIcon />
|
||||||
Auth Portal
|
Auth Portal
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{APPS.map((app) => {
|
{apps.map((app) => {
|
||||||
const Icon = APP_ICONS[app.id]
|
const Icon = ICON_MAP[app.icon] ?? ServerIcon
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={app.id}
|
key={app.id}
|
||||||
@@ -83,7 +93,7 @@ export function AppSwitcher() {
|
|||||||
render={<a href={app.url} target="_blank" rel="noreferrer" />}
|
render={<a href={app.url} target="_blank" rel="noreferrer" />}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
{app.title}
|
{app.name}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Admin editor for portal App Switcher URLs.
|
||||||
|
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import {
|
||||||
|
APP_IDS,
|
||||||
|
appSwitcherConfigSchema,
|
||||||
|
type AppId,
|
||||||
|
type AppSwitcherConfig,
|
||||||
|
type AppSwitcherIconName,
|
||||||
|
} from '@authportal/shared'
|
||||||
|
import { Button } from '@authportal/ui/components/button'
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||||
|
import { Input } from '@authportal/ui/components/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@authportal/ui/components/select'
|
||||||
|
import { Switch } from '@authportal/ui/components/switch'
|
||||||
|
import { ItemSeparator } from '@authportal/ui/components/item'
|
||||||
|
|
||||||
|
const ICON_OPTIONS: AppSwitcherIconName[] = [
|
||||||
|
'server',
|
||||||
|
'cloud',
|
||||||
|
'globe',
|
||||||
|
'dashboard',
|
||||||
|
'chart',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface AppSwitcherAdminEditorProps {
|
||||||
|
defaultValues: AppSwitcherConfig
|
||||||
|
onSave: (values: AppSwitcherConfig) => void
|
||||||
|
isSaving?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSwitcherAdminEditor({
|
||||||
|
defaultValues,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: AppSwitcherAdminEditorProps) {
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(appSwitcherConfigSchema),
|
||||||
|
defaultValues,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(defaultValues)
|
||||||
|
}, [defaultValues, form])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
onSubmit={(e) =>
|
||||||
|
void form.handleSubmit((values) => onSave(values))(e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="menu-label">Заголовок меню</FieldLabel>
|
||||||
|
<Input id="menu-label" {...form.register('menuLabel')} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{APP_IDS.map((appId, index) => {
|
||||||
|
const apps = form.watch('apps')
|
||||||
|
const appIndex = apps.findIndex((a) => a.id === appId)
|
||||||
|
if (appIndex < 0) return null
|
||||||
|
return (
|
||||||
|
<div key={appId} className="flex flex-col gap-3">
|
||||||
|
{index > 0 ? <ItemSeparator /> : null}
|
||||||
|
<p className="text-sm font-medium tracking-tight">
|
||||||
|
{appId.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field className="sm:col-span-2">
|
||||||
|
<FieldLabel htmlFor={`name-${appId}`}>Название</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`name-${appId}`}
|
||||||
|
{...form.register(`apps.${appIndex}.name`)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="sm:col-span-2">
|
||||||
|
<FieldLabel htmlFor={`url-${appId}`}>URL</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`url-${appId}`}
|
||||||
|
{...form.register(`apps.${appIndex}.url`)}
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field className="sm:col-span-2">
|
||||||
|
<FieldLabel htmlFor={`subtitle-${appId}`}>
|
||||||
|
Описание
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`subtitle-${appId}`}
|
||||||
|
{...form.register(`apps.${appIndex}.subtitle`)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`icon-${appId}`}>Иконка</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={form.watch(`apps.${appIndex}.icon`)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
form.setValue(
|
||||||
|
`apps.${appIndex}.icon`,
|
||||||
|
(v ?? 'server') as AppSwitcherIconName,
|
||||||
|
{ shouldDirty: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`icon-${appId}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ICON_OPTIONS.map((icon) => (
|
||||||
|
<SelectItem key={icon} value={icon}>
|
||||||
|
{icon}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal" className="items-center pt-6">
|
||||||
|
<Switch
|
||||||
|
checked={form.watch(`apps.${appIndex}.enabled`) !== false}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
form.setValue(`apps.${appIndex}.enabled`, v, {
|
||||||
|
shouldDirty: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldLabel className="font-normal">Включено в switcher</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...form.register(`apps.${appIndex}.id`)}
|
||||||
|
value={appId satisfies AppId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-fit"
|
||||||
|
disabled={isSaving || !form.formState.isDirty}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import type { AppSwitcherConfig } from '@authportal/shared'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const appSwitcherQueryKey = ['app-switcher'] as const
|
||||||
|
export const adminAppSwitcherQueryKey = ['admin', 'app-switcher'] as const
|
||||||
|
|
||||||
|
export const appSwitcherQueryOptions = queryOptions({
|
||||||
|
queryKey: appSwitcherQueryKey,
|
||||||
|
queryFn: () => api.get<AppSwitcherConfig>('/api/v1/app-switcher'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const adminAppSwitcherQueryOptions = queryOptions({
|
||||||
|
queryKey: adminAppSwitcherQueryKey,
|
||||||
|
queryFn: () => api.get<AppSwitcherConfig>('/api/v1/admin/app-switcher'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function putAppSwitcher(config: AppSwitcherConfig) {
|
||||||
|
return api.put<AppSwitcherConfig>('/api/v1/admin/app-switcher', config)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { Route as LogoutRouteImport } from './routes/logout'
|
|||||||
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
|
||||||
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
|
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
|
||||||
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
||||||
|
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
|
||||||
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -46,6 +47,11 @@ const AuthAdminIndexRoute = AuthAdminIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthAdminRoute,
|
getParentRoute: () => AuthAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthAdminAppsRoute = AuthAdminAppsRouteImport.update({
|
||||||
|
id: '/apps',
|
||||||
|
path: '/apps',
|
||||||
|
getParentRoute: () => AuthAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||||
id: '/users/$userId',
|
id: '/users/$userId',
|
||||||
path: '/users/$userId',
|
path: '/users/$userId',
|
||||||
@@ -57,6 +63,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/admin': typeof AuthAdminRouteWithChildren
|
'/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
'/admin/': typeof AuthAdminIndexRoute
|
'/admin/': typeof AuthAdminIndexRoute
|
||||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -64,6 +71,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/apps': typeof AuthAppsRoute
|
'/apps': typeof AuthAppsRoute
|
||||||
|
'/admin/apps': typeof AuthAdminAppsRoute
|
||||||
'/admin': typeof AuthAdminIndexRoute
|
'/admin': typeof AuthAdminIndexRoute
|
||||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
@@ -74,15 +82,28 @@ export interface FileRoutesById {
|
|||||||
'/logout': typeof LogoutRoute
|
'/logout': typeof LogoutRoute
|
||||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||||
'/_auth/apps': typeof AuthAppsRoute
|
'/_auth/apps': typeof AuthAppsRoute
|
||||||
|
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||||
'/_auth/admin/': typeof AuthAdminIndexRoute
|
'/_auth/admin/': typeof AuthAdminIndexRoute
|
||||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
'/' | '/logout' | '/admin' | '/apps' | '/admin/' | '/admin/users/$userId'
|
| '/'
|
||||||
|
| '/logout'
|
||||||
|
| '/admin'
|
||||||
|
| '/apps'
|
||||||
|
| '/admin/apps'
|
||||||
|
| '/admin/'
|
||||||
|
| '/admin/users/$userId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/logout' | '/apps' | '/admin' | '/admin/users/$userId'
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/logout'
|
||||||
|
| '/apps'
|
||||||
|
| '/admin/apps'
|
||||||
|
| '/admin'
|
||||||
|
| '/admin/users/$userId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -90,6 +111,7 @@ export interface FileRouteTypes {
|
|||||||
| '/logout'
|
| '/logout'
|
||||||
| '/_auth/admin'
|
| '/_auth/admin'
|
||||||
| '/_auth/apps'
|
| '/_auth/apps'
|
||||||
|
| '/_auth/admin/apps'
|
||||||
| '/_auth/admin/'
|
| '/_auth/admin/'
|
||||||
| '/_auth/admin/users/$userId'
|
| '/_auth/admin/users/$userId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -144,6 +166,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthAdminIndexRouteImport
|
preLoaderRoute: typeof AuthAdminIndexRouteImport
|
||||||
parentRoute: typeof AuthAdminRoute
|
parentRoute: typeof AuthAdminRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/admin/apps': {
|
||||||
|
id: '/_auth/admin/apps'
|
||||||
|
path: '/apps'
|
||||||
|
fullPath: '/admin/apps'
|
||||||
|
preLoaderRoute: typeof AuthAdminAppsRouteImport
|
||||||
|
parentRoute: typeof AuthAdminRoute
|
||||||
|
}
|
||||||
'/_auth/admin/users/$userId': {
|
'/_auth/admin/users/$userId': {
|
||||||
id: '/_auth/admin/users/$userId'
|
id: '/_auth/admin/users/$userId'
|
||||||
path: '/users/$userId'
|
path: '/users/$userId'
|
||||||
@@ -155,11 +184,13 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AuthAdminRouteChildren {
|
interface AuthAdminRouteChildren {
|
||||||
|
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
|
||||||
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
||||||
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
||||||
|
AuthAdminAppsRoute: AuthAdminAppsRoute,
|
||||||
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
||||||
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
|
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { defaultAppSwitcherConfig } from '@authportal/shared'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
|
import { ApiError } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
adminAppSwitcherQueryKey,
|
||||||
|
adminAppSwitcherQueryOptions,
|
||||||
|
appSwitcherQueryKey,
|
||||||
|
putAppSwitcher,
|
||||||
|
} from '@/queries/app-switcher'
|
||||||
|
import { catalogQueryKey } from '@/queries/auth'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/admin/apps')({
|
||||||
|
component: AdminAppsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AdminAppsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading, isError, error } = useQuery(
|
||||||
|
adminAppSwitcherQueryOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: putAppSwitcher,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: adminAppSwitcherQueryKey })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey })
|
||||||
|
void queryClient.invalidateQueries({ queryKey: catalogQueryKey })
|
||||||
|
toast.success('Ссылки приложений сохранены')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof ApiError ? err.message : 'Не удалось сохранить',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="flex flex-col gap-px">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
URL и подписи для App Switcher (CFDM, VPS Tracker, EvoBGP)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Ссылки сервисов</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Публичный конфиг: GET /api/v1/app-switcher — читают приложения
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Skeleton className="h-9 w-full max-w-md" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{error instanceof ApiError
|
||||||
|
? error.message
|
||||||
|
: 'Не удалось загрузить'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<AppSwitcherAdminEditor
|
||||||
|
defaultValues={data ?? defaultAppSwitcherConfig()}
|
||||||
|
onSave={(values) => saveMutation.mutate(values)}
|
||||||
|
isSaving={saveMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { LayoutGridIcon } from 'lucide-react'
|
import { LayoutGridIcon } from 'lucide-react'
|
||||||
import { APPS, buildSsoRedirectUrl, type AppId } from '@authportal/shared'
|
import { buildSsoRedirectUrl, type AppId } from '@authportal/shared'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -15,24 +15,14 @@ import {
|
|||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
import { getToken } from '@/lib/auth'
|
import { getToken } from '@/lib/auth'
|
||||||
import { meQueryOptions } from '@/queries/auth'
|
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/apps')({
|
export const Route = createFileRoute('/_auth/apps')({
|
||||||
component: AppsPage,
|
component: AppsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const APP_URL_OVERRIDES: Partial<Record<AppId, string | undefined>> = {
|
function openApp(_appId: AppId, baseUrl: string) {
|
||||||
vps: import.meta.env.VITE_VPS_APP_URL,
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
cfdm: import.meta.env.VITE_CFDM_APP_URL,
|
|
||||||
bgp: import.meta.env.VITE_BGP_APP_URL,
|
|
||||||
}
|
|
||||||
|
|
||||||
function appLaunchUrl(appId: AppId, defaultUrl: string): string {
|
|
||||||
return APP_URL_OVERRIDES[appId] || defaultUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
function openApp(appId: AppId, defaultUrl: string) {
|
|
||||||
const base = appLaunchUrl(appId, defaultUrl).replace(/\/$/, '')
|
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
window.open(base, '_blank', 'noreferrer')
|
window.open(base, '_blank', 'noreferrer')
|
||||||
@@ -44,9 +34,13 @@ function openApp(appId: AppId, defaultUrl: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppsPage() {
|
function AppsPage() {
|
||||||
const { data: me, isLoading } = useQuery(meQueryOptions)
|
const { data: me, isLoading: meLoading } = useQuery(meQueryOptions)
|
||||||
|
const { data: catalog, isLoading: catalogLoading } = useQuery(
|
||||||
|
catalogQueryOptions,
|
||||||
|
)
|
||||||
|
const isLoading = meLoading || catalogLoading
|
||||||
const allowed = new Set(me?.apps ?? [])
|
const allowed = new Set(me?.apps ?? [])
|
||||||
const apps = APPS.filter((app) => allowed.has(app.id))
|
const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -61,10 +55,10 @@ function AppsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
render={<Link to="/admin" />}
|
render={<Link to="/admin/apps" />}
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
>
|
>
|
||||||
Админка
|
Ссылки приложений
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ pnpm --filter web dev
|
|||||||
|
|
||||||
`AUTH_REQUIRED=false` — локальный login (`ADMIN_*`) для тестов/dev без portal; UI `/login`.
|
`AUTH_REQUIRED=false` — локальный login (`ADMIN_*`) для тестов/dev без portal; UI `/login`.
|
||||||
|
|
||||||
|
## App Switcher
|
||||||
|
|
||||||
|
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher` (CORS open). CFDM chrome (`AppSwitcher` / `AppsMenu`) читает его через `ensureAuthConfig().portalUrl`; offline fallback — hardcoded defaults с ids `cfdm` | `vps` | `bgp`.
|
||||||
|
|
||||||
|
Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В CFDM Settings → Integrations — read-only ссылка на портал.
|
||||||
|
|
||||||
|
`CURRENT_APP_ID = cfdm`. Если в JWT есть `apps[]` — в меню только пересечение с каталогом.
|
||||||
|
|
||||||
## UI аккаунта
|
## UI аккаунта
|
||||||
|
|
||||||
SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`.
|
SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`.
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ pnpm --filter web dev # :5173
|
|||||||
|
|
||||||
`AUTH_REQUIRED=false` — auth выключен (удобно для локальной разработки без portal); данные в `space-main`.
|
`AUTH_REQUIRED=false` — auth выключен (удобно для локальной разработки без portal); данные в `space-main`.
|
||||||
|
|
||||||
|
## App Switcher
|
||||||
|
|
||||||
|
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher`. VPS chrome читает его через `ensureAuthConfig().portalUrl`; offline fallback — defaults с ids `cfdm` | `vps` | `bgp`.
|
||||||
|
|
||||||
|
Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В VPS Settings → Integrations — read-only ссылка.
|
||||||
|
|
||||||
|
`CURRENT_APP_ID = vps`. Фильтр меню по JWT `apps[]` при наличии claims.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Симптом | Причина |
|
| Симптом | Причина |
|
||||||
@@ -109,7 +117,7 @@ pnpm --filter web dev # :5173
|
|||||||
| Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` |
|
| Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` |
|
||||||
| Infinite SSO / 429 | Просроченный JWT в portal localStorage; или разный `JWT_SECRET`/`ISSUER`. Portal чистит expired token; VPS блокирует повторный handoff 12с |
|
| Infinite SSO / 429 | Просроченный JWT в portal localStorage; или разный `JWT_SECRET`/`ISSUER`. Portal чистит expired token; VPS блокирует повторный handoff 12с |
|
||||||
| «Выйти» сразу возвращает в приложение | Старый клиент редиректил на `/?return_to=…` при живой portal-сессии. Нужен редирект на **`/logout`** (см. ниже) |
|
| «Выйти» сразу возвращает в приложение | Старый клиент редиректил на `/?return_to=…` при живой portal-сессии. Нужен редирект на **`/logout`** (см. ниже) |
|
||||||
| CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token |
|
| CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token; public app-switcher GET тоже CORS-open |
|
||||||
|
|
||||||
## Logout (SSO)
|
## Logout (SSO)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ Surface lock: **`frame`** (ReUI Frame). Не смешивать shadcn Card и F
|
|||||||
Nav groups Auth Portal:
|
Nav groups Auth Portal:
|
||||||
|
|
||||||
- **Портал:** Приложения (`/apps`)
|
- **Портал:** Приложения (`/apps`)
|
||||||
- **Админ** (только `is_admin`): Пользователи (`/admin`)
|
- **Админ** (только `is_admin`): Пользователи (`/admin`), Ссылки приложений (`/admin/apps`)
|
||||||
|
|
||||||
|
App Switcher (source of truth): `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. UI: `/admin/apps` ([settings-16](https://reui.io/preview/base/settings-16)). Ids: `cfdm` · `vps` · `bgp`. Consumers (CFDM, vps-tracker) только читают public API.
|
||||||
|
|
||||||
## Spacing
|
## Spacing
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export function migrateSchema(sqlite: Sqlite): void {
|
|||||||
created_at TEXT NOT NULL
|
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_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_user_permissions_user ON user_permissions(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(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 './schema/index.js'
|
||||||
export * from './users.js'
|
export * from './users.js'
|
||||||
|
export * from './settings.js'
|
||||||
|
|||||||
@@ -35,3 +35,10 @@ export const refreshSessions = sqliteTable('refresh_sessions', {
|
|||||||
revokedAt: text('revoked_at'),
|
revokedAt: text('revoked_at'),
|
||||||
createdAt: text('created_at').notNull(),
|
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/auth.js'
|
||||||
|
export * from './contracts/app-switcher.js'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user