adminpanel login fix

This commit is contained in:
Untone 2025-05-16 10:30:02 +03:00
parent 2d382be794
commit 11e46f7352
13 changed files with 174 additions and 406 deletions

View File

@ -43,7 +43,7 @@ async def logout(request: Request):
logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}") logger.error(f"[auth] logout: Ошибка при отзыве токена: {e}")
# Создаем ответ с редиректом на страницу входа # Создаем ответ с редиректом на страницу входа
response = RedirectResponse(url="/login") response = RedirectResponse(url="/")
# Удаляем cookie с токеном # Удаляем cookie с токеном
response.delete_cookie(SESSION_COOKIE_NAME) response.delete_cookie(SESSION_COOKIE_NAME)

View File

@ -105,8 +105,8 @@ async def admin_handler(request: Request):
""" """
# Проверяем авторизован ли пользователь # Проверяем авторизован ли пользователь
if not request.user.is_authenticated: if not request.user.is_authenticated:
# Если пользователь не авторизован, перенаправляем на страницу входа # Если пользователь не авторизован, перенаправляем на главную страницу
return RedirectResponse(url="/login", status_code=303) return RedirectResponse(url="/", status_code=303)
# Проверяем является ли пользователь администратором # Проверяем является ли пользователь администратором
auth = getattr(request, "auth", None) auth = getattr(request, "auth", None)
@ -199,9 +199,7 @@ async def shutdown():
# Добавляем маршруты статических файлов, если директория существует # Добавляем маршруты статических файлов, если директория существует
routes = [] routes = []
if exists(DIST_DIR): if exists(DIST_DIR):
# Добавляем маршруты для статических ресурсов, если директория dist существует routes.append(Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)))
routes.append(Mount("/assets", app=StaticFiles(directory=join(DIST_DIR, "assets"))))
routes.append(Mount("/chunks", app=StaticFiles(directory=join(DIST_DIR, "chunks"))))
# Маршруты для API и веб-приложения # Маршруты для API и веб-приложения
routes.extend( routes.extend(

149
package-lock.json generated
View File

@ -7,17 +7,11 @@
"": { "": {
"name": "publy-admin", "name": "publy-admin",
"version": "0.4.20", "version": "0.4.20",
"dependencies": {
"@solid-primitives/storage": "^4.3.0",
"@solidjs/router": "^0.15.0",
"graphql": "^16.8.0",
"graphql-request": "^6.1.0",
"solid-js": "^1.9.6",
"solid-styled-components": "^0.28.0"
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@types/node": "^22.15.0", "@types/node": "^22.15.0",
"graphql": "^16.8.0",
"solid-js": "^1.9.6",
"terser": "^5.39.0", "terser": "^5.39.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vite": "^6.3.0", "vite": "^6.3.0",
@ -883,15 +877,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8", "version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@ -1236,45 +1221,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@solid-primitives/storage": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@solid-primitives/storage/-/storage-4.3.2.tgz",
"integrity": "sha512-Vkuk/AqgUOjz6k7Mo5yDFQBQg8KMQcZfaac/bxLApaza3e5c/iNllNvxZWPM9Vf+Gf4m5SgRbvgsm6dSLJ27Jw==",
"license": "MIT",
"dependencies": {
"@solid-primitives/utils": "^6.3.1"
},
"peerDependencies": {
"@tauri-apps/plugin-store": "*",
"solid-js": "^1.6.12"
},
"peerDependenciesMeta": {
"@tauri-apps/plugin-store": {
"optional": true
},
"solid-start": {
"optional": true
}
}
},
"node_modules/@solid-primitives/utils": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.3.1.tgz",
"integrity": "sha512-4/Z59nnwu4MPR//zWZmZm2yftx24jMqQ8CSd/JobL26TPfbn4Ph8GKNVJfGJWShg1QB98qObJSskqizbTvcLLA==",
"license": "MIT",
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/@solidjs/router": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.15.3.tgz",
"integrity": "sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw==",
"license": "MIT",
"peerDependencies": {
"solid-js": "^1.8.6"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1469,19 +1415,11 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -1623,37 +1561,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graphql": { "node_modules/graphql": {
"version": "16.11.0", "version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
} }
}, },
"node_modules/graphql-request": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz",
"integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"cross-fetch": "^3.1.5"
},
"peerDependencies": {
"graphql": "14 - 16"
}
},
"node_modules/html-entities": { "node_modules/html-entities": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
@ -1759,26 +1676,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@ -1902,6 +1799,7 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.0.tgz", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.0.tgz",
"integrity": "sha512-4tYQDy3HVM0JjJ1CfDK3K8FhBKIDDri27oc2AyabuuHfQw6/yTDPp2Abt1h2cNtf1R0T+7AQYAzPhUgqXztaXw==", "integrity": "sha512-4tYQDy3HVM0JjJ1CfDK3K8FhBKIDDri27oc2AyabuuHfQw6/yTDPp2Abt1h2cNtf1R0T+7AQYAzPhUgqXztaXw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -1911,6 +1809,7 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.0.tgz", "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.0.tgz",
"integrity": "sha512-FFu/UE3uA8L1vj0CXXZo2Nlh10MtYoOs0G//ptwlQMjfPFSeIVYUNy0zewfV8iM0CrOebAfHEG6J3xA9c+lsaQ==", "integrity": "sha512-FFu/UE3uA8L1vj0CXXZo2Nlh10MtYoOs0G//ptwlQMjfPFSeIVYUNy0zewfV8iM0CrOebAfHEG6J3xA9c+lsaQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -1923,6 +1822,7 @@
"version": "1.9.6", "version": "1.9.6",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.6.tgz", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.6.tgz",
"integrity": "sha512-PoasAJvLk60hRtOTe9ulvALOdLjjqxuxcGZRolBQqxOnXrBXHGzqMT4ijNhGsDAYdOgEa8ZYaAE94PSldrFSkA==", "integrity": "sha512-PoasAJvLk60hRtOTe9ulvALOdLjjqxuxcGZRolBQqxOnXrBXHGzqMT4ijNhGsDAYdOgEa8ZYaAE94PSldrFSkA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.1.0", "csstype": "^3.1.0",
@ -1945,19 +1845,6 @@
"solid-js": "^1.3" "solid-js": "^1.3"
} }
}, },
"node_modules/solid-styled-components": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/solid-styled-components/-/solid-styled-components-0.28.5.tgz",
"integrity": "sha512-vwTcdp76wZNnESIzB6rRZ3U55NgcSAQXCiiRIiEFhxTFqT0bEh/warNT1qaRZu4OkAzrBkViOngF35ktI8sc4A==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.0",
"goober": "^2.1.10"
},
"peerDependencies": {
"solid-js": "^1.4.4"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -2025,12 +1912,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@ -2209,22 +2090,6 @@
} }
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -7,33 +7,21 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"lint": "biome check .", "lint": "biome check . --fix",
"format": "biome format . --write", "format": "biome format . --write",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "vitest", "test": "vitest",
"build:auth": "vite build -c client/auth/vite.config.ts", "build:auth": "vite build -c client/auth/vite.config.ts",
"watch:auth": "vite build -c client/auth/vite.config.ts --watch" "watch:auth": "vite build -c client/auth/vite.config.ts --watch"
}, },
"dependencies": {
"@solidjs/router": "^0.15.0",
"@solid-primitives/storage": "^4.3.0",
"graphql": "^16.8.0",
"graphql-request": "^6.1.0",
"solid-js": "^1.9.6",
"solid-styled-components": "^0.28.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.15.0",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@types/node": "^22.15.0",
"graphql": "^16.8.0",
"solid-js": "^1.9.6",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vite": "^6.3.0", "vite": "^6.3.0",
"vite-plugin-solid": "^2.11.0", "vite-plugin-solid": "^2.11.0",
"terser": "^5.39.0" "terser": "^5.39.0"
},
"exports": {
".": {
"import": "./dist/auth.es.js",
"require": "./dist/auth.umd.js"
}
} }
} }

View File

@ -1,110 +1,61 @@
import { Route, Router, RouteSectionProps } from '@solidjs/router' import { Component, Show, Suspense, createSignal, lazy, onMount } from 'solid-js'
import { Component, Suspense, lazy } from 'solid-js'
import { isAuthenticated } from './auth' import { isAuthenticated } from './auth'
// Ленивая загрузка компонентов // Ленивая загрузка компонентов
const LoginPage = lazy(() => import('./login'))
const AdminPage = lazy(() => import('./admin')) const AdminPage = lazy(() => import('./admin'))
const LoginPage = lazy(() => import('./login'))
/** /**
* Компонент корневого шаблона приложения * Корневой компонент приложения с простой логикой отображения
* @param props - Свойства маршрута, включающие дочерние элементы
*/
const RootLayout: Component<RouteSectionProps> = (props) => {
return (
<div class="app-container">
{/* Здесь может быть общий хедер, футер или другие элементы */}
{props.children}
</div>
)
}
/**
* Компонент защиты маршрутов
* Проверяет авторизацию и либо показывает дочерние элементы,
* либо перенаправляет на страницу входа
*/
const RequireAuth: Component<RouteSectionProps> = (props) => {
const authed = isAuthenticated()
if (!authed) {
// Если не авторизован, перенаправляем на /login
window.location.href = '/login'
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление на страницу входа...</h2>
</div>
)
}
return <>{props.children}</>
}
/**
* Компонент для публичных маршрутов с редиректом,
* если пользователь уже авторизован
*/
const PublicOnlyRoute: Component<RouteSectionProps> = (props) => {
// Если пользователь авторизован, перенаправляем на админ-панель
if (isAuthenticated()) {
window.location.href = '/admin'
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление в админ-панель...</h2>
</div>
)
}
return <>{props.children}</>
}
/**
* Компонент перенаправления с корневого маршрута
*/
const RootRedirect: Component = () => {
const authenticated = isAuthenticated()
// Выполняем перенаправление сразу после рендеринга
setTimeout(() => {
window.location.href = authenticated ? '/admin' : '/login'
}, 100)
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление...</h2>
</div>
)
}
/**
* Корневой компонент приложения с настроенными маршрутами
*/ */
const App: Component = () => { const App: Component = () => {
const [authenticated, setAuthenticated] = createSignal<boolean | null>(null)
const [loading, setLoading] = createSignal(true)
// Проверяем авторизацию при монтировании
onMount(() => {
const authed = isAuthenticated()
setAuthenticated(authed)
setLoading(false)
})
// Обработчик успешной авторизации
const handleLoginSuccess = () => {
setAuthenticated(true)
}
// Обработчик выхода из системы
const handleLogout = () => {
setAuthenticated(false)
}
return ( return (
<Router root={RootLayout}> <div class="app-container">
<Suspense fallback={ <Suspense
fallback={
<div class="loading-screen"> <div class="loading-screen">
<div class="loading-spinner"></div> <div class="loading-spinner" />
<h2>Загрузка...</h2> <h2>Загрузка...</h2>
</div> </div>
}> }
{/* Корневой маршрут с перенаправлением */} >
<Route path="/" component={RootRedirect} /> <Show
when={!loading()}
{/* Маршрут логина (только для неавторизованных) */} fallback={
<Route path="/login" component={PublicOnlyRoute}> <div class="loading-screen">
<Route path="/" component={LoginPage} /> <div class="loading-spinner" />
</Route> <h2>Загрузка...</h2>
</div>
{/* Защищенные маршруты (только для авторизованных) */} }
<Route path="/admin" component={RequireAuth}> >
<Route path="/*" component={AdminPage} /> {authenticated() ? (
</Route> <AdminPage onLogout={handleLogout} />
) : (
<LoginPage onLoginSuccess={handleLoginSuccess} />
)}
</Show>
</Suspense> </Suspense>
</Router> </div>
) )
} }

View File

@ -3,10 +3,9 @@
* @module AdminPage * @module AdminPage
*/ */
import { useNavigate } from '@solidjs/router' import { Component, For, Show, createSignal, onMount } from 'solid-js'
import { Component, For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js' import { logout } from './auth'
import { query } from './graphql' import { query } from './graphql'
import { isAuthenticated, logout } from './auth'
/** /**
* Интерфейс для данных пользователя * Интерфейс для данных пользователя
@ -52,10 +51,15 @@ interface AdminGetRolesResponse {
adminGetRoles: Role[] adminGetRoles: Role[]
} }
// Интерфейс для пропсов AdminPage
interface AdminPageProps {
onLogout?: () => void
}
/** /**
* Компонент страницы администратора * Компонент страницы администратора
*/ */
const AdminPage: Component = () => { const AdminPage: Component<AdminPageProps> = (props) => {
const [activeTab, setActiveTab] = createSignal('users') const [activeTab, setActiveTab] = createSignal('users')
const [users, setUsers] = createSignal<User[]>([]) const [users, setUsers] = createSignal<User[]>([])
const [roles, setRoles] = createSignal<Role[]>([]) const [roles, setRoles] = createSignal<Role[]>([])
@ -81,8 +85,6 @@ const AdminPage: Component = () => {
// Поиск // Поиск
const [searchQuery, setSearchQuery] = createSignal('') const [searchQuery, setSearchQuery] = createSignal('')
const navigate = useNavigate()
// Периодическая проверка авторизации // Периодическая проверка авторизации
onMount(() => { onMount(() => {
// Загружаем данные при монтировании // Загружаем данные при монтировании
@ -103,6 +105,7 @@ const AdminPage: Component = () => {
const search = searchQuery().trim() const search = searchQuery().trim()
const data = await query<AdminGetUsersResponse>( const data = await query<AdminGetUsersResponse>(
`${location.origin}/graphql`,
` `
query AdminGetUsers($limit: Int, $offset: Int, $search: String) { query AdminGetUsers($limit: Int, $offset: Int, $search: String) {
adminGetUsers(limit: $limit, offset: $offset, search: $search) { adminGetUsers(limit: $limit, offset: $offset, search: $search) {
@ -160,7 +163,9 @@ const AdminPage: Component = () => {
*/ */
async function loadRoles() { async function loadRoles() {
try { try {
const data = await query<AdminGetRolesResponse>(` const data = await query<AdminGetRolesResponse>(
`${location.origin}/graphql`,
`
query AdminGetRoles { query AdminGetRoles {
adminGetRoles { adminGetRoles {
id id
@ -168,7 +173,8 @@ const AdminPage: Component = () => {
description description
} }
} }
`) `
)
if (data?.adminGetRoles) { if (data?.adminGetRoles) {
setRoles(data.adminGetRoles) setRoles(data.adminGetRoles)
@ -249,6 +255,7 @@ const AdminPage: Component = () => {
try { try {
await query( await query(
`${location.origin}/graphql`,
` `
mutation AdminToggleUserBlock($userId: Int!) { mutation AdminToggleUserBlock($userId: Int!) {
adminToggleUserBlock(userId: $userId) { adminToggleUserBlock(userId: $userId) {
@ -295,6 +302,7 @@ const AdminPage: Component = () => {
try { try {
await query( await query(
`${location.origin}/graphql`,
` `
mutation AdminToggleUserMute($userId: Int!) { mutation AdminToggleUserMute($userId: Int!) {
adminToggleUserMute(userId: $userId) { adminToggleUserMute(userId: $userId) {
@ -343,6 +351,7 @@ const AdminPage: Component = () => {
async function updateUserRoles(userId: number, newRoles: string[]) { async function updateUserRoles(userId: number, newRoles: string[]) {
try { try {
await query( await query(
`${location.origin}/graphql`,
` `
mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) { mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) {
adminUpdateUser(userId: $userId, input: $input) { adminUpdateUser(userId: $userId, input: $input) {
@ -391,8 +400,10 @@ const AdminPage: Component = () => {
// Затем выполняем выход // Затем выполняем выход
logout(() => { logout(() => {
// Для гарантии перенаправления после выхода // Вызываем коллбэк для оповещения родителя о выходе
window.location.href = '/login' if (props.onLogout) {
props.onLogout()
}
}) })
} }

View File

@ -5,6 +5,28 @@
import { query } from './graphql' import { query } from './graphql'
// Константа для имени ключа токена в localStorage
const AUTH_COOKIE_NAME = 'auth_token'
// Константа для имени ключа токена в cookie
export const AUTH_TOKEN_KEY = 'auth_token'
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
export const getAuthTokenFromCookie = (): string => {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === 'auth_token') {
return value
}
}
return ''
}
/** /**
* Интерфейс для учетных данных * Интерфейс для учетных данных
*/ */
@ -29,31 +51,6 @@ interface LoginResponse {
login: LoginResult login: LoginResult
} }
/**
* Константа для имени ключа токена в localStorage
*/
const AUTH_TOKEN_KEY = 'auth_token'
/**
* Константа для имени ключа токена в cookie
*/
const AUTH_COOKIE_NAME = 'auth_token'
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
function getAuthTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === AUTH_COOKIE_NAME) {
return value
}
}
return ''
}
/** /**
* Проверяет, авторизован ли пользователь * Проверяет, авторизован ли пользователь
* @returns Статус авторизации * @returns Статус авторизации
@ -87,7 +84,7 @@ export function logout(callback?: () => void): void {
fetch('/logout', { fetch('/logout', {
method: 'GET', method: 'GET',
credentials: 'include' credentials: 'include'
}).catch(e => { }).catch((e) => {
console.error('Ошибка при запросе на выход:', e) console.error('Ошибка при запросе на выход:', e)
}) })
} catch (e) { } catch (e) {
@ -107,6 +104,7 @@ export async function login(credentials: Credentials): Promise<boolean> {
try { try {
// Используем query из graphql.ts для выполнения запроса // Используем query из graphql.ts для выполнения запроса
const data = await query<LoginResponse>( const data = await query<LoginResponse>(
`${location.origin}/graphql`,
` `
mutation Login($email: String!, $password: String!) { mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) { login(email: $email, password: $password) {
@ -141,3 +139,4 @@ export async function login(credentials: Credentials): Promise<boolean> {
throw error throw error
} }
} }

View File

@ -3,37 +3,13 @@
* @module api * @module api
*/ */
/** import { AUTH_TOKEN_KEY, getAuthTokenFromCookie } from "./auth"
* Базовый URL для API
*/
// Всегда используем абсолютный путь к API
const API_URL = window.location.origin + '/graphql'
/**
* Константа для имени ключа токена в localStorage
*/
const AUTH_TOKEN_KEY = 'auth_token'
/** /**
* Тип для произвольных данных GraphQL * Тип для произвольных данных GraphQL
*/ */
type GraphQLData = Record<string, unknown> type GraphQLData = Record<string, unknown>
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
function getAuthTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === 'auth_token') {
return value
}
}
return ''
}
/** /**
* Обрабатывает ошибки от API * Обрабатывает ошибки от API
* @param response - Ответ от сервера * @param response - Ответ от сервера
@ -74,13 +50,12 @@ async function handleApiError(response: Response): Promise<string> {
function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean { function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean {
return errors.some( return errors.some(
(error) => (error) =>
(error.message && ( (error.message &&
error.message.toLowerCase().includes('unauthorized') || (error.message.toLowerCase().includes('unauthorized') ||
error.message.toLowerCase().includes('авторизации') || error.message.toLowerCase().includes('авторизации') ||
error.message.toLowerCase().includes('authentication') || error.message.toLowerCase().includes('authentication') ||
error.message.toLowerCase().includes('unauthenticated') || error.message.toLowerCase().includes('unauthenticated') ||
error.message.toLowerCase().includes('token') error.message.toLowerCase().includes('token'))) ||
)) ||
error.extensions?.code === 'UNAUTHENTICATED' || error.extensions?.code === 'UNAUTHENTICATED' ||
error.extensions?.code === 'FORBIDDEN' error.extensions?.code === 'FORBIDDEN'
) )
@ -88,11 +63,13 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
/** /**
* Выполняет GraphQL запрос * Выполняет GraphQL запрос
* @param url - URL для запроса
* @param query - GraphQL запрос * @param query - GraphQL запрос
* @param variables - Переменные запроса * @param variables - Переменные запроса
* @returns Результат запроса * @returns Результат запроса
*/ */
export async function query<T = GraphQLData>( export async function query<T = GraphQLData>(
url: string,
query: string, query: string,
variables: Record<string, unknown> = {} variables: Record<string, unknown> = {}
): Promise<T> { ): Promise<T> {
@ -118,7 +95,7 @@ export async function query<T = GraphQLData>(
console.debug('Отправка запроса с токеном авторизации') console.debug('Отправка запроса с токеном авторизации')
} }
const response = await fetch(API_URL, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers, headers,
// Важно: credentials: 'include' - для передачи cookies с запросом // Важно: credentials: 'include' - для передачи cookies с запросом
@ -141,7 +118,7 @@ export async function query<T = GraphQLData>(
// Если получен 401 Unauthorized, перенаправляем на страницу входа // Если получен 401 Unauthorized, перенаправляем на страницу входа
if (response.status === 401) { if (response.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY) localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/login' window.location.href = '/'
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
@ -161,7 +138,7 @@ export async function query<T = GraphQLData>(
// Проверяем ошибки на признаки проблем с авторизацией // Проверяем ошибки на признаки проблем с авторизацией
if (hasAuthErrors(result.errors)) { if (hasAuthErrors(result.errors)) {
localStorage.removeItem(AUTH_TOKEN_KEY) localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/login' window.location.href = '/'
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
@ -177,13 +154,15 @@ export async function query<T = GraphQLData>(
/** /**
* Выполняет GraphQL мутацию * Выполняет GraphQL мутацию
* @param url - URL для запроса
* @param mutation - GraphQL мутация * @param mutation - GraphQL мутация
* @param variables - Переменные мутации * @param variables - Переменные мутации
* @returns Результат мутации * @returns Результат мутации
*/ */
export function mutate<T = GraphQLData>( export function mutate<T = GraphQLData>(
url: string,
mutation: string, mutation: string,
variables: Record<string, unknown> = {} variables: Record<string, unknown> = {}
): Promise<T> { ): Promise<T> {
return query<T>(mutation, variables) return query<T>(url, mutation, variables)
} }

View File

@ -3,30 +3,21 @@
* @module LoginPage * @module LoginPage
*/ */
import { useNavigate } from '@solidjs/router' import { Component, createSignal } from 'solid-js'
import { Component, createSignal, onMount } from 'solid-js' import { login } from './auth'
import { login, isAuthenticated } from './auth'
interface LoginPageProps {
onLoginSuccess?: () => void
}
/** /**
* Компонент страницы входа * Компонент страницы входа
*/ */
const LoginPage: Component = () => { const LoginPage: Component<LoginPageProps> = (props) => {
const [email, setEmail] = createSignal('') const [email, setEmail] = createSignal('')
const [password, setPassword] = createSignal('') const [password, setPassword] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false) const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
const navigate = useNavigate()
/**
* Проверка авторизации при загрузке компонента
* и перенаправление если пользователь уже авторизован
*/
onMount(() => {
// Если пользователь уже авторизован, перенаправляем на админ-панель
if (isAuthenticated()) {
window.location.href = '/admin'
}
})
/** /**
* Обработчик отправки формы входа * Обработчик отправки формы входа
@ -54,8 +45,10 @@ const LoginPage: Component = () => {
}) })
if (loginSuccessful) { if (loginSuccessful) {
// Используем прямое перенаправление для надежности // Вызываем коллбэк для оповещения родителя об успешном входе
window.location.href = '/admin' if (props.onLoginSuccess) {
props.onLoginSuccess()
}
} else { } else {
throw new Error('Вход не выполнен') throw new Error('Вход не выполнен')
} }

View File

@ -585,3 +585,16 @@ button.unmute {
gap: 10px; gap: 10px;
} }
} }
.loading-spinner {
width: 40px;
height: 40px;
border-radius: 50%;
animation: spin 6s linear infinite;
background-color: transparent;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -1,12 +1,6 @@
{ {
"include": [ "include": ["."],
"." "exclude": ["**/node_modules", "**/__pycache__", "**/.*", "**/dist"],
],
"exclude": [
"**/node_modules",
"**/__pycache__",
"**/.*"
],
"defineConstant": { "defineConstant": {
"DEBUG": true "DEBUG": true
}, },

View File

@ -16,8 +16,7 @@
"isolatedModules": true, "isolatedModules": true,
"lib": ["DOM", "ESNext"], "lib": ["DOM", "ESNext"],
"paths": { "paths": {
"~/*": ["panel/admin/*"], "~/*": ["./panel/*"]
"@/*": ["panel/auth/*"]
} }
}, },
"exclude": [] "exclude": []

View File

@ -7,34 +7,12 @@ const isProd = process.env.NODE_ENV === 'production'
export default defineConfig({ export default defineConfig({
plugins: [solidPlugin()], plugins: [solidPlugin()],
base: '/',
build: { build: {
target: 'esnext', target: 'esnext',
outDir: 'dist', outDir: 'dist',
minify: isProd, minify: isProd,
sourcemap: !isProd, sourcemap: !isProd,
rollupOptions: {
input: {
main: resolve(__dirname, 'client/index.tsx')
},
output: {
// Настройка выходных файлов
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name].[hash].js',
assetFileNames: 'assets/[name].[hash][extname]',
// Настройка разделения кода
manualChunks: {
vendor: ['solid-js', '@solidjs/router'],
graphql: ['./client/graphql.ts'],
auth: ['./client/auth.ts']
}
}
},
// Оптимизация сборки // Оптимизация сборки
cssCodeSplit: true, cssCodeSplit: true,
assetsInlineLimit: 4096, assetsInlineLimit: 4096,
@ -65,7 +43,7 @@ export default defineConfig({
// Настройка алиасов для путей // Настройка алиасов для путей
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'client') '~': resolve(__dirname, 'panel')
} }
} }
}) })