feat(app-switcher): централизовать ссылки приложений в portal settings
Публичный GET и admin PUT/UI /admin/apps; каталог и chrome читают URL из store. Co-authored-by: Cursor <cursoragent@cursor.com>
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: 'admin@test.local',
|
||||
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: 'admin@test.local', 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 { 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 { meQueryOptions } from '@/queries/auth'
|
||||
import {
|
||||
@@ -57,13 +57,23 @@ export function AppSidebar() {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Пользователи"
|
||||
isActive={isActive(pathname, '/admin', false)}
|
||||
isActive={isActive(pathname, '/admin', false) && !pathname.startsWith('/admin/apps')}
|
||||
render={<Link to="/admin" />}
|
||||
>
|
||||
<UsersIcon className="size-4" />
|
||||
<span>Пользователи</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Приложения"
|
||||
isActive={isActive(pathname, '/admin/apps', false)}
|
||||
render={<Link to="/admin/apps" />}
|
||||
>
|
||||
<AppWindowIcon className="size-4" />
|
||||
<span>Ссылки приложений</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { APPS } from '@authportal/shared'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
CloudIcon,
|
||||
ServerIcon,
|
||||
NetworkIcon,
|
||||
LayoutDashboardIcon,
|
||||
ChartColumnIcon,
|
||||
} from 'lucide-react'
|
||||
import type { AppSwitcherIconName } from '@authportal/shared'
|
||||
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -24,18 +28,24 @@ const PORTAL = {
|
||||
id: 'portal',
|
||||
name: 'Auth Portal',
|
||||
subtitle: 'shnt.top',
|
||||
url: '/',
|
||||
icon: KeyRoundIcon,
|
||||
}
|
||||
|
||||
const APP_ICONS = {
|
||||
cfdm: CloudIcon,
|
||||
vps: ServerIcon,
|
||||
bgp: NetworkIcon,
|
||||
} as const
|
||||
const ICON_MAP: Record<
|
||||
AppSwitcherIconName,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
cloud: CloudIcon,
|
||||
server: ServerIcon,
|
||||
globe: NetworkIcon,
|
||||
dashboard: LayoutDashboardIcon,
|
||||
chart: ChartColumnIcon,
|
||||
}
|
||||
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { data } = useQuery(appSwitcherQueryOptions)
|
||||
const menuLabel = data?.menuLabel ?? 'Приложения'
|
||||
const apps = (data?.apps ?? []).filter((a) => a.enabled !== false)
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -67,15 +77,15 @@ export function AppSwitcher() {
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
Приложения
|
||||
{menuLabel}
|
||||
</div>
|
||||
<DropdownMenuItem disabled>
|
||||
<KeyRoundIcon />
|
||||
Auth Portal
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
{APPS.map((app) => {
|
||||
const Icon = APP_ICONS[app.id]
|
||||
{apps.map((app) => {
|
||||
const Icon = ICON_MAP[app.icon] ?? ServerIcon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
@@ -83,7 +93,7 @@ export function AppSwitcher() {
|
||||
render={<a href={app.url} target="_blank" rel="noreferrer" />}
|
||||
>
|
||||
<Icon />
|
||||
{app.title}
|
||||
{app.name}
|
||||
</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 AuthAppsRouteImport } from './routes/_auth.apps'
|
||||
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'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -46,6 +47,11 @@ const AuthAdminIndexRoute = AuthAdminIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminAppsRoute = AuthAdminAppsRouteImport.update({
|
||||
id: '/apps',
|
||||
path: '/apps',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||
id: '/users/$userId',
|
||||
path: '/users/$userId',
|
||||
@@ -57,6 +63,7 @@ export interface FileRoutesByFullPath {
|
||||
'/logout': typeof LogoutRoute
|
||||
'/admin': typeof AuthAdminRouteWithChildren
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -64,6 +71,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -74,15 +82,28 @@ export interface FileRoutesById {
|
||||
'/logout': typeof LogoutRoute
|
||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||
'/_auth/apps': typeof AuthAppsRoute
|
||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/_auth/admin/': typeof AuthAdminIndexRoute
|
||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
'/' | '/logout' | '/admin' | '/apps' | '/admin/' | '/admin/users/$userId'
|
||||
| '/'
|
||||
| '/logout'
|
||||
| '/admin'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin/'
|
||||
| '/admin/users/$userId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/logout' | '/apps' | '/admin' | '/admin/users/$userId'
|
||||
to:
|
||||
| '/'
|
||||
| '/logout'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin'
|
||||
| '/admin/users/$userId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -90,6 +111,7 @@ export interface FileRouteTypes {
|
||||
| '/logout'
|
||||
| '/_auth/admin'
|
||||
| '/_auth/apps'
|
||||
| '/_auth/admin/apps'
|
||||
| '/_auth/admin/'
|
||||
| '/_auth/admin/users/$userId'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -144,6 +166,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthAdminIndexRouteImport
|
||||
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': {
|
||||
id: '/_auth/admin/users/$userId'
|
||||
path: '/users/$userId'
|
||||
@@ -155,11 +184,13 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
|
||||
interface AuthAdminRouteChildren {
|
||||
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
|
||||
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
||||
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
|
||||
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
||||
AuthAdminAppsRoute: AuthAdminAppsRoute,
|
||||
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
||||
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 { useQuery } from '@tanstack/react-query'
|
||||
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 { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
@@ -15,24 +15,14 @@ import {
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { getToken } from '@/lib/auth'
|
||||
import { meQueryOptions } from '@/queries/auth'
|
||||
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
|
||||
|
||||
export const Route = createFileRoute('/_auth/apps')({
|
||||
component: AppsPage,
|
||||
})
|
||||
|
||||
const APP_URL_OVERRIDES: Partial<Record<AppId, string | undefined>> = {
|
||||
vps: import.meta.env.VITE_VPS_APP_URL,
|
||||
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(/\/$/, '')
|
||||
function openApp(_appId: AppId, baseUrl: string) {
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
window.open(base, '_blank', 'noreferrer')
|
||||
@@ -44,9 +34,13 @@ function openApp(appId: AppId, defaultUrl: string) {
|
||||
}
|
||||
|
||||
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 apps = APPS.filter((app) => allowed.has(app.id))
|
||||
const apps = (catalog?.apps ?? []).filter((app) => allowed.has(app.id))
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -61,10 +55,10 @@ function AppsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/admin" />}
|
||||
render={<Link to="/admin/apps" />}
|
||||
nativeButton={false}
|
||||
>
|
||||
Админка
|
||||
Ссылки приложений
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,14 @@ pnpm --filter web dev
|
||||
|
||||
`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 аккаунта
|
||||
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
| Симптом | Причина |
|
||||
@@ -109,7 +117,7 @@ pnpm --filter web dev # :5173
|
||||
| Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` |
|
||||
| Infinite SSO / 429 | Просроченный JWT в portal localStorage; или разный `JWT_SECRET`/`ISSUER`. Portal чистит expired token; VPS блокирует повторный handoff 12с |
|
||||
| «Выйти» сразу возвращает в приложение | Старый клиент редиректил на `/?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)
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ Surface lock: **`frame`** (ReUI Frame). Не смешивать shadcn Card и F
|
||||
Nav groups Auth Portal:
|
||||
|
||||
- **Портал:** Приложения (`/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
|
||||
|
||||
|
||||
@@ -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