diff --git a/README.md b/README.md
index 283015c..bebab73 100644
--- a/README.md
+++ b/README.md
@@ -161,3 +161,17 @@ fetch(`${Config.API_URL}/api`, {
Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо
запросов вида http://localhost:8000/api должно быть так: /api
+# Авторизация фронтенда
+
+По умолчанию фронтенд использует существующую локальную форму входа. Keycloak включается только при точном значении `true` у production-флага:
+
+```dotenv
+REACT_APP_IS_PRODUCTION=true
+REACT_APP_KEYCLOAK_URL=https://keycloak.example.ru
+REACT_APP_REALM=example-realm
+REACT_APP_CLIENT_ID=example-client
+REACT_APP_NAMESPACE=example-namespace
+REACT_APP_APP_NAME=example-app
+```
+
+При `REACT_APP_IS_PRODUCTION=false` или если переменная отсутствует, Keycloak не инициализируется.
diff --git a/web/package-lock.json b/web/package-lock.json
index 1a7ae02..5debc9c 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -14,6 +14,7 @@
"@mui/x-tree-view": "^9.1.0",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
+ "keycloak-js": "^26.2.4",
"material-react-table": "^3.2.1",
"react": "^18.3.1",
"react-daisyui": "^5.0.5",
@@ -2362,6 +2363,15 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
+ "node_modules/keycloak-js": {
+ "version": "26.2.4",
+ "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz",
+ "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==",
+ "license": "Apache-2.0",
+ "workspaces": [
+ "test"
+ ]
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
diff --git a/web/package.json b/web/package.json
index f2eee39..5a3bde3 100644
--- a/web/package.json
+++ b/web/package.json
@@ -15,6 +15,7 @@
"@mui/x-tree-view": "^9.1.0",
"axios": "^1.9.0",
"date-fns": "^4.1.0",
+ "keycloak-js": "^26.2.4",
"material-react-table": "^3.2.1",
"react": "^18.3.1",
"react-daisyui": "^5.0.5",
diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx
index 57b4861..e011d85 100644
--- a/web/src/app/Routes.jsx
+++ b/web/src/app/Routes.jsx
@@ -25,7 +25,7 @@ export const PrivateRoute = () => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
- ;
+ return ;
}
return isAuthenticated ? : ;
diff --git a/web/src/app/context/AuthProvider.jsx b/web/src/app/context/AuthProvider.jsx
index 964ee0f..fcc73de 100644
--- a/web/src/app/context/AuthProvider.jsx
+++ b/web/src/app/context/AuthProvider.jsx
@@ -1,12 +1,20 @@
import { CircularProgress } from '@mui/material';
+import Keycloak from 'keycloak-js';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { AuthApi } from '../../api/auth';
import { UsersApi } from '../../api/users';
import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token';
+import { Config } from '../../conf/config';
const AuthContext = createContext(null);
-export const AuthProvider = ({ children }) => {
+const LoadingScreen = () => (
+
+
+
+);
+
+const LocalAuthProvider = ({ children }) => {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(() => {
@@ -99,18 +107,123 @@ export const AuthProvider = ({ children }) => {
}, []);
return (
-
- {loading ? (
-
-
-
- ) : (
- children
- )}
+
+ {loading ? : children}
);
};
+const KeycloakAuthProvider = ({ children }) => {
+ const [keycloak] = useState(
+ () =>
+ new Keycloak({
+ url: Config.KEYCLOAK_URL,
+ realm: Config.REALM,
+ clientId: Config.CLIENT_ID,
+ }),
+ );
+ const [loading, setLoading] = useState(true);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [user, setUser] = useState(null);
+
+ const saveKeycloakToken = useCallback(() => {
+ if (!keycloak.token) return;
+
+ localStorage.setItem('access_token', keycloak.token);
+ localStorage.removeItem('refresh_token');
+ window.dispatchEvent(new CustomEvent('auth:changed', { detail: { loggedIn: true } }));
+ }, [keycloak]);
+
+ const loadUser = useCallback(async () => {
+ try {
+ const userData = await UsersApi.me();
+ setUser(userData);
+ } catch (error) {
+ console.error('Ошибка загрузки пользователя:', error);
+ setUser(null);
+ }
+ }, []);
+
+ const login = useCallback(() => keycloak.login(), [keycloak]);
+
+ const logout = useCallback(async () => {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ setIsAuthenticated(false);
+ setUser(null);
+ await keycloak.logout({ redirectUri: window.location.origin });
+ }, [keycloak]);
+
+ useEffect(() => {
+ let active = true;
+
+ const initialize = async () => {
+ try {
+ const authenticated = await keycloak.init({
+ onLoad: 'login-required',
+ checkLoginIframe: false,
+ pkceMethod: 'S256',
+ });
+
+ if (!active) return;
+ setIsAuthenticated(authenticated);
+
+ if (authenticated) {
+ saveKeycloakToken();
+ await loadUser();
+ }
+ } catch (error) {
+ console.error('Ошибка инициализации Keycloak:', error);
+ if (active) {
+ setIsAuthenticated(false);
+ setUser(null);
+ }
+ } finally {
+ if (active) setLoading(false);
+ }
+ };
+
+ keycloak.onAuthRefreshSuccess = saveKeycloakToken;
+ keycloak.onTokenExpired = async () => {
+ try {
+ await keycloak.updateToken(30);
+ saveKeycloakToken();
+ } catch (error) {
+ console.error('Не удалось обновить токен Keycloak:', error);
+ await login();
+ }
+ };
+ keycloak.onAuthLogout = () => {
+ localStorage.removeItem('access_token');
+ setIsAuthenticated(false);
+ setUser(null);
+ };
+
+ initialize();
+
+ return () => {
+ active = false;
+ keycloak.onAuthRefreshSuccess = undefined;
+ keycloak.onTokenExpired = undefined;
+ keycloak.onAuthLogout = undefined;
+ };
+ }, [keycloak, loadUser, login, saveKeycloakToken]);
+
+ return (
+
+ {loading ? : children}
+
+ );
+};
+
+export const AuthProvider = ({ children }) => {
+ if (Config.IS_PRODUCTION) {
+ return {children};
+ }
+
+ return {children};
+};
+
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts
index 90f6c29..e12d670 100644
--- a/web/src/conf/config.ts
+++ b/web/src/conf/config.ts
@@ -1,9 +1,11 @@
//@ts-ignore
const environment = process.env;
+const isTrue = (value: string | undefined): boolean => value?.trim().toLowerCase() === 'true';
+
let env_var: string | undefined;
-if (environment.APP_ENV == 'test') {
+if (environment.APP_ENV === 'test') {
env_var = environment.OPENBAO__SETTINGS_TEST__REACT_APP_API_URL;
} else {
env_var = environment.OPENBAO__SETTINGS__REACT_APP_API_URL;
@@ -11,6 +13,7 @@ if (environment.APP_ENV == 'test') {
export class Config {
static API_URL: string = env_var ?? environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '';
+ static IS_PRODUCTION: boolean = isTrue(environment.REACT_APP_IS_PRODUCTION);
static KEYCLOAK_URL: string = environment.REACT_APP_KEYCLOAK_URL ?? '';
static REALM: string = environment.REACT_APP_REALM ?? '';
static CLIENT_ID: string = environment.REACT_APP_CLIENT_ID ?? '';
diff --git a/web/src/env.d.ts b/web/src/env.d.ts
index b0ac762..5cd9f50 100644
--- a/web/src/env.d.ts
+++ b/web/src/env.d.ts
@@ -1 +1,12 @@
///
+
+declare namespace NodeJS {
+ interface ProcessEnv {
+ readonly REACT_APP_IS_PRODUCTION?: string;
+ readonly REACT_APP_KEYCLOAK_URL?: string;
+ readonly REACT_APP_REALM?: string;
+ readonly REACT_APP_CLIENT_ID?: string;
+ readonly REACT_APP_NAMESPACE?: string;
+ readonly REACT_APP_APP_NAME?: string;
+ }
+}