feat: добавить новый backend и cabinet
- добавлен новый Nest backend для auth, projects и project access tokens - добавлена control-plane схема БД и миграция Drizzle - перенесён старый backend в old-backend - добавлен React/Vite cabinet с auth-only входом и Mantine layout - обновлены workspace scripts и lockfile
This commit is contained in:
12
apps/cabinet/index.html
Normal file
12
apps/cabinet/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Assets Cabinet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
apps/cabinet/package.json
Normal file
36
apps/cabinet/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@image-platform/cabinet",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"codegen:backend-api": "npx @gromlab/api-codegen@latest -i http://localhost:3001/docs-json -o src/infra/backend-api/generated -n backend-api.generated",
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 5174",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.1.1",
|
||||
"@mantine/form": "^9.1.1",
|
||||
"@mantine/hooks": "^9.1.1",
|
||||
"@mantine/notifications": "^9.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"swr": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-global-data": "^4.0.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss-custom-media": "^12.0.1",
|
||||
"postcss-nesting": "^14.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
10
apps/cabinet/postcss.config.mjs
Normal file
10
apps/cabinet/postcss.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@csstools/postcss-global-data': {
|
||||
files: ['src/shared/styles/media.css'],
|
||||
},
|
||||
'postcss-custom-media': {},
|
||||
'postcss-nesting': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
23
apps/cabinet/src/app/app-router.tsx
Normal file
23
apps/cabinet/src/app/app-router.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { LoginScreen } from 'screens/login'
|
||||
import { SessionScreen } from 'screens/session'
|
||||
|
||||
import { AuthenticatedShell } from './authenticated-shell'
|
||||
import { ProtectedRoute } from './protected-route'
|
||||
|
||||
export const AppRouter = () => (
|
||||
<Routes>
|
||||
<Route element={<LoginScreen />} path="/login" />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AuthenticatedShell>
|
||||
<SessionScreen />
|
||||
</AuthenticatedShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
path="/"
|
||||
/>
|
||||
<Route element={<Navigate replace to="/" />} path="*" />
|
||||
</Routes>
|
||||
)
|
||||
17
apps/cabinet/src/app/app.tsx
Normal file
17
apps/cabinet/src/app/app.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { SWRConfig } from 'swr'
|
||||
import { ThemeProvider } from 'infra/theme'
|
||||
|
||||
import { AppRouter } from './app-router'
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<SWRConfig value={{ shouldRetryOnError: false }}>
|
||||
<ThemeProvider>
|
||||
<AppRouter />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
40
apps/cabinet/src/app/authenticated-shell.tsx
Normal file
40
apps/cabinet/src/app/authenticated-shell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Loader, Center } from '@mantine/core'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { authFactory } from 'business/auth'
|
||||
import { MainLayout } from 'layouts/main'
|
||||
|
||||
import type { AuthenticatedShellProps } from './types/authenticated-shell-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Обёртка защищённого layout кабинета.
|
||||
*
|
||||
* Используется для:
|
||||
* - передачи admin-сессии в MainLayout
|
||||
* - выполнения выхода из кабинета
|
||||
*/
|
||||
export const AuthenticatedShell = (props: AuthenticatedShellProps) => {
|
||||
const { children } = props
|
||||
const navigate = useNavigate()
|
||||
const session = auth.useAdminSession()
|
||||
const logoutAction = auth.useLogout()
|
||||
|
||||
const handleLogout = () => {
|
||||
void logoutAction.logout().then(() => navigate('/login', { replace: true }))
|
||||
}
|
||||
|
||||
if (!session.session) {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout username={session.session.username} onLogout={handleLogout}>
|
||||
{children}
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
18
apps/cabinet/src/app/main.tsx
Normal file
18
apps/cabinet/src/app/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'shared/styles/global.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { App } from './app'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Root element #root not found')
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
33
apps/cabinet/src/app/protected-route.tsx
Normal file
33
apps/cabinet/src/app/protected-route.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Loader, Center } from '@mantine/core'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { authFactory } from 'business/auth'
|
||||
|
||||
import type { ProtectedRouteProps } from './types/protected-route-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Guard защищённых маршрутов кабинета.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки admin-сессии через SWR
|
||||
* - перенаправления неавторизованного пользователя на вход
|
||||
*/
|
||||
export const ProtectedRoute = (props: ProtectedRouteProps) => {
|
||||
const { children } = props
|
||||
const session = auth.useAdminSession()
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session.isAuthenticated) {
|
||||
return <Navigate replace to="/login" />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Пропсы AuthenticatedShell.
|
||||
*/
|
||||
export type AuthenticatedShellProps = {
|
||||
/** Контент защищённого маршрута. */
|
||||
children: ReactNode
|
||||
}
|
||||
9
apps/cabinet/src/app/types/protected-route-props.type.ts
Normal file
9
apps/cabinet/src/app/types/protected-route-props.type.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Пропсы ProtectedRoute.
|
||||
*/
|
||||
export type ProtectedRouteProps = {
|
||||
/** Защищённый route element. */
|
||||
children: ReactNode
|
||||
}
|
||||
15
apps/cabinet/src/business/auth/auth.factory.ts
Normal file
15
apps/cabinet/src/business/auth/auth.factory.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useAdminSession } from './hooks/use-admin-session.hook'
|
||||
import { useLogin } from './hooks/use-login.hook'
|
||||
import { useLogout } from './hooks/use-logout.hook'
|
||||
import type { AuthFactory } from './types/auth-factory.type'
|
||||
|
||||
/**
|
||||
* Создаёт runtime API бизнес-модуля Auth.
|
||||
*/
|
||||
export const authFactory: AuthFactory = () => {
|
||||
return {
|
||||
useAdminSession,
|
||||
useLogin,
|
||||
useLogout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getAdminToken, useGetAdminSession } from 'infra/backend-api'
|
||||
|
||||
import type { AdminSession } from '../types/auth-api.type'
|
||||
|
||||
/**
|
||||
* Состояние текущей admin-сессии.
|
||||
*/
|
||||
export const useAdminSession = (): AdminSession => {
|
||||
const hasToken = Boolean(getAdminToken())
|
||||
const sessionQuery = useGetAdminSession({
|
||||
shouldRetryOnError: false,
|
||||
})
|
||||
|
||||
return {
|
||||
error: sessionQuery.error,
|
||||
isAuthenticated: Boolean(sessionQuery.data),
|
||||
isLoading: hasToken && sessionQuery.isLoading,
|
||||
session: sessionQuery.data ?? null,
|
||||
}
|
||||
}
|
||||
40
apps/cabinet/src/business/auth/hooks/use-login.hook.ts
Normal file
40
apps/cabinet/src/business/auth/hooks/use-login.hook.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
import { backendApi, getAdminSessionKey, setAdminToken } from 'infra/backend-api'
|
||||
|
||||
import { toError } from '../lib/to-error'
|
||||
import type { LoginAction, LoginInput } from '../types/auth-api.type'
|
||||
|
||||
/**
|
||||
* Сценарий входа администратора.
|
||||
*/
|
||||
export const useLogin = (): LoginAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false)
|
||||
|
||||
const login = async (input: LoginInput) => {
|
||||
setError(null)
|
||||
setIsLoggingIn(true)
|
||||
|
||||
try {
|
||||
const session = await backendApi.auth.login(input)
|
||||
setAdminToken(session.accessToken)
|
||||
await mutate(getAdminSessionKey())
|
||||
|
||||
return session
|
||||
} catch (caughtError) {
|
||||
const nextError = toError(caughtError)
|
||||
setError(nextError)
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsLoggingIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
isLoggingIn,
|
||||
login,
|
||||
}
|
||||
}
|
||||
20
apps/cabinet/src/business/auth/hooks/use-logout.hook.ts
Normal file
20
apps/cabinet/src/business/auth/hooks/use-logout.hook.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useSWRConfig } from 'swr'
|
||||
import { clearAdminToken, getAdminSessionKey } from 'infra/backend-api'
|
||||
|
||||
import type { LogoutAction } from '../types/auth-api.type'
|
||||
|
||||
/**
|
||||
* Сценарий выхода администратора.
|
||||
*/
|
||||
export const useLogout = (): LogoutAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const logout = async () => {
|
||||
clearAdminToken()
|
||||
await mutate(getAdminSessionKey(), undefined, { revalidate: false })
|
||||
}
|
||||
|
||||
return {
|
||||
logout,
|
||||
}
|
||||
}
|
||||
3
apps/cabinet/src/business/auth/index.ts
Normal file
3
apps/cabinet/src/business/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { authFactory } from './auth.factory'
|
||||
export type { AdminSession, AuthApi, LoginAction, LoginInput, LogoutAction } from './types/auth-api.type'
|
||||
export type { AuthFactory } from './types/auth-factory.type'
|
||||
7
apps/cabinet/src/business/auth/lib/to-error.ts
Normal file
7
apps/cabinet/src/business/auth/lib/to-error.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const toError = (value: unknown) => {
|
||||
if (value instanceof Error) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Error(String(value))
|
||||
}
|
||||
29
apps/cabinet/src/business/auth/types/auth-api.type.ts
Normal file
29
apps/cabinet/src/business/auth/types/auth-api.type.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from 'infra/backend-api'
|
||||
|
||||
export type LoginInput = LoginRequestDto
|
||||
|
||||
export type AdminSession = {
|
||||
error?: Error
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
session: AdminSessionResponseDto | null
|
||||
}
|
||||
|
||||
export type LoginAction = {
|
||||
error: Error | null
|
||||
isLoggingIn: boolean
|
||||
login: (input: LoginInput) => Promise<LoginResponseDto>
|
||||
}
|
||||
|
||||
export type LogoutAction = {
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный runtime API бизнес-модуля Auth.
|
||||
*/
|
||||
export type AuthApi = {
|
||||
useAdminSession: () => AdminSession
|
||||
useLogin: () => LoginAction
|
||||
useLogout: () => LogoutAction
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { AuthApi } from './auth-api.type'
|
||||
|
||||
export type AuthFactory = () => AuthApi
|
||||
77
apps/cabinet/src/infra/backend-api/client.ts
Normal file
77
apps/cabinet/src/infra/backend-api/client.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { getAdminToken } from './token-storage'
|
||||
import type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from './types/backend-api.type'
|
||||
|
||||
type RequestOptions = {
|
||||
body?: unknown
|
||||
isAuthorized?: boolean
|
||||
method?: 'GET' | 'POST'
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_API_BASE_URL ?? '/api'
|
||||
|
||||
export const backendApi = {
|
||||
auth: {
|
||||
login: (body: LoginRequestDto) => {
|
||||
return request<LoginResponseDto>('/auth/login', { body, method: 'POST' })
|
||||
},
|
||||
me: () => {
|
||||
return request<AdminSessionResponseDto>('/auth/me', { isAuthorized: true })
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
headers: buildHeaders(options),
|
||||
method: options.method ?? 'GET',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function buildHeaders(options: RequestOptions) {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
|
||||
if (options.body) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (options.isAuthorized) {
|
||||
const token = getAdminToken()
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const value = (await response.json()) as unknown
|
||||
|
||||
if (isRecord(value) && typeof value.message === 'string') {
|
||||
return value.message
|
||||
}
|
||||
|
||||
if (isRecord(value) && Array.isArray(value.message)) {
|
||||
return value.message.join(', ')
|
||||
}
|
||||
} catch {
|
||||
return `request failed with status ${response.status}`
|
||||
}
|
||||
|
||||
return `request failed with status ${response.status}`
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
1
apps/cabinet/src/infra/backend-api/hooks/index.ts
Normal file
1
apps/cabinet/src/infra/backend-api/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getAdminSessionKey, useGetAdminSession } from './use-get-admin-session.hook'
|
||||
@@ -0,0 +1,18 @@
|
||||
import useSWR from 'swr'
|
||||
import type { SWRConfiguration } from 'swr'
|
||||
|
||||
import { backendApi } from '../client'
|
||||
import { getAdminToken } from '../token-storage'
|
||||
import type { AdminSessionResponseDto } from '../types/backend-api.type'
|
||||
|
||||
export const getAdminSessionKey = () => ['backend-api', 'auth', 'me'] as const
|
||||
|
||||
/**
|
||||
* Получение текущей admin-сессии.
|
||||
*/
|
||||
export const useGetAdminSession = (config?: SWRConfiguration<AdminSessionResponseDto>) => {
|
||||
const key = getAdminToken() ? getAdminSessionKey() : null
|
||||
const fetcher = () => backendApi.auth.me()
|
||||
|
||||
return useSWR<AdminSessionResponseDto>(key, fetcher, config)
|
||||
}
|
||||
4
apps/cabinet/src/infra/backend-api/index.ts
Normal file
4
apps/cabinet/src/infra/backend-api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { backendApi } from './client'
|
||||
export { clearAdminToken, getAdminToken, setAdminToken } from './token-storage'
|
||||
export * from './hooks'
|
||||
export type { AdminSessionResponseDto, LoginRequestDto, LoginResponseDto } from './types/backend-api.type'
|
||||
13
apps/cabinet/src/infra/backend-api/token-storage.ts
Normal file
13
apps/cabinet/src/infra/backend-api/token-storage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const ADMIN_TOKEN_KEY = 'image-platform.cabinet.admin-token'
|
||||
|
||||
export const getAdminToken = () => {
|
||||
return window.localStorage.getItem(ADMIN_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export const setAdminToken = (token: string) => {
|
||||
window.localStorage.setItem(ADMIN_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export const clearAdminToken = () => {
|
||||
window.localStorage.removeItem(ADMIN_TOKEN_KEY)
|
||||
}
|
||||
14
apps/cabinet/src/infra/backend-api/types/backend-api.type.ts
Normal file
14
apps/cabinet/src/infra/backend-api/types/backend-api.type.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type LoginRequestDto = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type LoginResponseDto = {
|
||||
accessToken: string
|
||||
tokenType: 'Bearer'
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export type AdminSessionResponseDto = {
|
||||
username: string
|
||||
}
|
||||
2
apps/cabinet/src/infra/theme/index.ts
Normal file
2
apps/cabinet/src/infra/theme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ThemeProvider } from './theme-provider'
|
||||
export type { ThemeProviderProps } from './types/theme-provider-props.type'
|
||||
22
apps/cabinet/src/infra/theme/theme-provider.tsx
Normal file
22
apps/cabinet/src/infra/theme/theme-provider.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
|
||||
import type { ThemeProviderProps } from './types/theme-provider-props.type'
|
||||
|
||||
/**
|
||||
* Провайдер визуальной темы кабинета.
|
||||
*
|
||||
* Используется для:
|
||||
* - подключения Mantine theme
|
||||
* - подключения контейнера уведомлений
|
||||
*/
|
||||
export const ThemeProvider = (props: ThemeProviderProps) => {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="light">
|
||||
<Notifications position="top-right" />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Пропсы ThemeProvider.
|
||||
*/
|
||||
export type ThemeProviderProps = {
|
||||
/** Контент приложения. */
|
||||
children: ReactNode
|
||||
}
|
||||
2
apps/cabinet/src/layouts/main/index.ts
Normal file
2
apps/cabinet/src/layouts/main/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MainLayout } from './main.layout'
|
||||
export type { MainLayoutProps } from './types/main-layout-props.type'
|
||||
82
apps/cabinet/src/layouts/main/main.layout.tsx
Normal file
82
apps/cabinet/src/layouts/main/main.layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { AppShell, Burger, Button, Container, Group, NavLink, Stack, Text, ThemeIcon } from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import cl from 'clsx'
|
||||
|
||||
import styles from './styles/main-layout.module.css'
|
||||
import type { MainLayoutProps } from './types/main-layout-props.type'
|
||||
|
||||
/**
|
||||
* Основной layout кабинета с Mantine AppShell.
|
||||
*
|
||||
* Используется для:
|
||||
* - оборачивания защищённых маршрутов кабинета
|
||||
* - отображения шапки, сайдбара и футера
|
||||
*/
|
||||
export const MainLayout = (props: MainLayoutProps) => {
|
||||
const { children, className, onLogout, username, ...rootAttrs } = props
|
||||
const [isNavbarOpened, navbar] = useDisclosure(false)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
{...rootAttrs}
|
||||
className={cl(styles.root, className)}
|
||||
footer={{ height: 48 }}
|
||||
header={{ height: 72 }}
|
||||
navbar={{ breakpoint: 'md', collapsed: { mobile: !isNavbarOpened }, width: 280 }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" justify="space-between" px={{ base: 'md', md: 'xl' }}>
|
||||
<Group gap="md">
|
||||
<Burger hiddenFrom="md" opened={isNavbarOpened} size="sm" onClick={navbar.toggle} />
|
||||
|
||||
<Group gap="sm" aria-label="Assets Delivery Platform">
|
||||
<ThemeIcon radius="xl" size={42} variant="light">
|
||||
AD
|
||||
</ThemeIcon>
|
||||
<Text fw={700} visibleFrom="sm">
|
||||
Assets Delivery
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Text c="dimmed" size="sm">
|
||||
{username}
|
||||
</Text>
|
||||
<Button color="gray" variant="light" onClick={onLogout}>
|
||||
Выйти
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<Stack gap="sm">
|
||||
<Text c="dimmed" fw={700} size="xs" tt="uppercase">
|
||||
Навигация
|
||||
</Text>
|
||||
<NavLink active label="Вход выполнен" />
|
||||
<NavLink disabled label="Проекты появятся позже" />
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Container py="xl" size="lg">
|
||||
{children}
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Footer>
|
||||
<Group h="100%" justify="space-between" px={{ base: 'md', md: 'xl' }}>
|
||||
<Text c="dimmed" size="sm">
|
||||
Control plane
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
REST + SWR
|
||||
</Text>
|
||||
</Group>
|
||||
</AppShell.Footer>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры MainLayout.
|
||||
*/
|
||||
export type MainLayoutParams = {
|
||||
/** Контент текущего маршрута. */
|
||||
children: ReactNode
|
||||
/** Имя авторизованного администратора. */
|
||||
username: string
|
||||
/** Обработчик выхода из кабинета. */
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type MainLayoutProps = RootAttrs & MainLayoutParams
|
||||
2
apps/cabinet/src/screens/login/index.ts
Normal file
2
apps/cabinet/src/screens/login/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LoginScreen } from './login.screen'
|
||||
export type { LoginScreenProps } from './types/login-screen-props.type'
|
||||
106
apps/cabinet/src/screens/login/login.screen.tsx
Normal file
106
apps/cabinet/src/screens/login/login.screen.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { useForm } from '@mantine/form'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
import cl from 'clsx'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { authFactory } from 'business/auth'
|
||||
|
||||
import styles from './styles/login.module.css'
|
||||
import type { LoginScreenProps } from './types/login-screen-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Экран входа администратора.
|
||||
*
|
||||
* Используется для:
|
||||
* - получения admin Bearer token
|
||||
* - входа в защищенный layout кабинета
|
||||
*/
|
||||
export const LoginScreen = (props: LoginScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const navigate = useNavigate()
|
||||
const session = auth.useAdminSession()
|
||||
const loginAction = auth.useLogin()
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password: 'admin',
|
||||
username: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
if (session.isAuthenticated) {
|
||||
return <Navigate replace to="/" />
|
||||
}
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
try {
|
||||
await loginAction.login(values)
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
message: 'Admin-сессия активна.',
|
||||
title: 'Вход выполнен',
|
||||
})
|
||||
navigate('/', { replace: true })
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message: error instanceof Error ? error.message : 'Не удалось войти.',
|
||||
title: 'Ошибка входа',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Center mih="100vh" p="md">
|
||||
<Box w="100%" maw={460}>
|
||||
<Stack gap="lg">
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon radius="xl" size={52} variant="light">
|
||||
AD
|
||||
</ThemeIcon>
|
||||
<Title order={1} ta="center">
|
||||
Assets Delivery
|
||||
</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
Вход в кабинет управления платформой.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Paper p="xl" radius="lg" shadow="sm" withBorder>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
{loginAction.error ? (
|
||||
<Alert color="red" radius="lg" title="Вход не выполнен">
|
||||
{loginAction.error.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<TextInput label="Логин" placeholder="admin" required {...form.getInputProps('username')} />
|
||||
<PasswordInput label="Пароль" placeholder="admin" required {...form.getInputProps('password')} />
|
||||
|
||||
<Button loading={loginAction.isLoggingIn} type="submit">
|
||||
Войти
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Center>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
3
apps/cabinet/src/screens/login/styles/login.module.css
Normal file
3
apps/cabinet/src/screens/login/styles/login.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры LoginScreen.
|
||||
*/
|
||||
export type LoginScreenParams = Record<string, never>
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||
|
||||
export type LoginScreenProps = RootAttrs & LoginScreenParams
|
||||
2
apps/cabinet/src/screens/session/index.ts
Normal file
2
apps/cabinet/src/screens/session/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SessionScreen } from './session.screen'
|
||||
export type { SessionScreenProps } from './types/session-screen-props.type'
|
||||
52
apps/cabinet/src/screens/session/session.screen.tsx
Normal file
52
apps/cabinet/src/screens/session/session.screen.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Badge, Group, Paper, Stack, Text, ThemeIcon, Title } from '@mantine/core'
|
||||
import cl from 'clsx'
|
||||
import { authFactory } from 'business/auth'
|
||||
|
||||
import styles from './styles/session.module.css'
|
||||
import type { SessionScreenProps } from './types/session-screen-props.type'
|
||||
|
||||
const auth = authFactory()
|
||||
|
||||
/**
|
||||
* Экран активной admin-сессии.
|
||||
*
|
||||
* Используется для:
|
||||
* - подтверждения успешного входа
|
||||
* - отображения базового состояния кабинета до продуктовых экранов
|
||||
*/
|
||||
export const SessionScreen = (props: SessionScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const session = auth.useAdminSession()
|
||||
|
||||
return (
|
||||
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Paper p="xl" radius="lg" shadow="sm" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Group gap="md">
|
||||
<ThemeIcon color="green" radius="xl" size={48} variant="light">
|
||||
OK
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={1}>Вход выполнен</Title>
|
||||
<Text c="dimmed">Admin-сессия получена через новый backend.</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Badge color="green" variant="light">
|
||||
active
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text>
|
||||
Пользователь: <strong>{session.session?.username ?? 'admin'}</strong>
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed">
|
||||
Продуктовые экраны не подключены. Следующим шагом сюда можно добавить проекты и токены доступа к проектам.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры SessionScreen.
|
||||
*/
|
||||
export type SessionScreenParams = Record<string, never>
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||
|
||||
export type SessionScreenProps = RootAttrs & SessionScreenParams
|
||||
3
apps/cabinet/src/shared/styles/global.css
Normal file
3
apps/cabinet/src/shared/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "@mantine/core/styles.css";
|
||||
@import "@mantine/notifications/styles.css";
|
||||
@import "./variables.css";
|
||||
17
apps/cabinet/src/shared/styles/media.css
Normal file
17
apps/cabinet/src/shared/styles/media.css
Normal file
@@ -0,0 +1,17 @@
|
||||
/* Ширина - Mobile First (min-width), кроме --xs (max-width). */
|
||||
@custom-media --xs (max-width: 35.9375rem);
|
||||
@custom-media --sm (min-width: 36rem);
|
||||
@custom-media --md (min-width: 48rem);
|
||||
@custom-media --lg (min-width: 62rem);
|
||||
@custom-media --xl (min-width: 75rem);
|
||||
@custom-media --2xl (min-width: 88rem);
|
||||
@custom-media --3xl (min-width: 120rem);
|
||||
|
||||
/* Высота - min-height. */
|
||||
@custom-media --h-xs (min-height: 41.6875rem);
|
||||
@custom-media --h-sm (min-height: 43.875rem);
|
||||
@custom-media --h-md (min-height: 50.625rem);
|
||||
@custom-media --h-lg (min-height: 56.25rem);
|
||||
@custom-media --h-xl (min-height: 62.5rem);
|
||||
@custom-media --h-2xl (min-height: 68.75rem);
|
||||
@custom-media --h-3xl (min-height: 75rem);
|
||||
12
apps/cabinet/src/shared/styles/variables.css
Normal file
12
apps/cabinet/src/shared/styles/variables.css
Normal file
@@ -0,0 +1,12 @@
|
||||
:root {
|
||||
--color-primary: #228be6;
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-hover: #f8f9fa;
|
||||
--color-text: #1a1b1e;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--radius-1: 4px;
|
||||
--radius-2: 8px;
|
||||
}
|
||||
1
apps/cabinet/src/vite-env.d.ts
vendored
Normal file
1
apps/cabinet/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
33
apps/cabinet/tsconfig.app.json
Normal file
33
apps/cabinet/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"app/*": ["src/app/*"],
|
||||
"layouts/*": ["src/layouts/*"],
|
||||
"screens/*": ["src/screens/*"],
|
||||
"widgets/*": ["src/widgets/*"],
|
||||
"business/*": ["src/business/*"],
|
||||
"infra/*": ["src/infra/*"],
|
||||
"ui/*": ["src/ui/*"],
|
||||
"shared/*": ["src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
apps/cabinet/tsconfig.json
Normal file
4
apps/cabinet/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
15
apps/cabinet/tsconfig.node.json
Normal file
15
apps/cabinet/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
31
apps/cabinet/vite.config.ts
Normal file
31
apps/cabinet/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const srcPath = (path: string) => fileURLToPath(new URL(`./src/${path}`, import.meta.url))
|
||||
const backendProxyTarget = process.env.CABINET_BACKEND_PROXY_TARGET ?? 'http://localhost:3001'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
app: srcPath('app'),
|
||||
layouts: srcPath('layouts'),
|
||||
screens: srcPath('screens'),
|
||||
widgets: srcPath('widgets'),
|
||||
business: srcPath('business'),
|
||||
infra: srcPath('infra'),
|
||||
ui: srcPath('ui'),
|
||||
shared: srcPath('shared'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
changeOrigin: true,
|
||||
target: backendProxyTarget,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user