This commit is contained in:
PotapovaA 2026-09-02 15:53:30 +03:00
parent 57290a8d03
commit 820d53e931
7 changed files with 163 additions and 11 deletions

View File

@ -161,3 +161,17 @@ fetch(`${Config.API_URL}/api`, {
Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо
запросов вида http://localhost:8000/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 не инициализируется.

10
web/package-lock.json generated
View File

@ -14,6 +14,7 @@
"@mui/x-tree-view": "^9.1.0", "@mui/x-tree-view": "^9.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"keycloak-js": "^26.2.4",
"material-react-table": "^3.2.1", "material-react-table": "^3.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-daisyui": "^5.0.5", "react-daisyui": "^5.0.5",
@ -2362,6 +2363,15 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",

View File

@ -15,6 +15,7 @@
"@mui/x-tree-view": "^9.1.0", "@mui/x-tree-view": "^9.1.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"keycloak-js": "^26.2.4",
"material-react-table": "^3.2.1", "material-react-table": "^3.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-daisyui": "^5.0.5", "react-daisyui": "^5.0.5",

View File

@ -25,7 +25,7 @@ export const PrivateRoute = () => {
const { isAuthenticated, loading } = useAuth(); const { isAuthenticated, loading } = useAuth();
if (loading) { if (loading) {
<CircularProgress />; return <CircularProgress />;
} }
return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace />; return isAuthenticated ? <Outlet /> : <Navigate to='/login' replace />;

View File

@ -1,12 +1,20 @@
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import Keycloak from 'keycloak-js';
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { AuthApi } from '../../api/auth'; import { AuthApi } from '../../api/auth';
import { UsersApi } from '../../api/users'; import { UsersApi } from '../../api/users';
import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token'; import { getAuthToken, isTokenExpired, saveAuthTokens } from '../../api/utils/token';
import { Config } from '../../conf/config';
const AuthContext = createContext(null); const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => { const LoadingScreen = () => (
<div className='flex items-center justify-center min-h-screen'>
<CircularProgress />
</div>
);
const LocalAuthProvider = ({ children }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(() => { const [isAuthenticated, setIsAuthenticated] = useState(() => {
@ -99,18 +107,123 @@ export const AuthProvider = ({ children }) => {
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ isAuthenticated, user, login, logout, loadUser }}> <AuthContext.Provider value={{ isAuthenticated, user, login, logout, loadUser, loading }}>
{loading ? ( {loading ? <LoadingScreen /> : children}
<div className='flex items-center justify-center min-h-screen'>
<CircularProgress />
</div>
) : (
children
)}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
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 (
<AuthContext.Provider value={{ isAuthenticated, user, login, logout, loadUser, loading }}>
{loading ? <LoadingScreen /> : children}
</AuthContext.Provider>
);
};
export const AuthProvider = ({ children }) => {
if (Config.IS_PRODUCTION) {
return <KeycloakAuthProvider>{children}</KeycloakAuthProvider>;
}
return <LocalAuthProvider>{children}</LocalAuthProvider>;
};
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {

View File

@ -1,9 +1,11 @@
//@ts-ignore //@ts-ignore
const environment = process.env; const environment = process.env;
const isTrue = (value: string | undefined): boolean => value?.trim().toLowerCase() === 'true';
let env_var: string | undefined; 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; env_var = environment.OPENBAO__SETTINGS_TEST__REACT_APP_API_URL;
} else { } else {
env_var = environment.OPENBAO__SETTINGS__REACT_APP_API_URL; env_var = environment.OPENBAO__SETTINGS__REACT_APP_API_URL;
@ -11,6 +13,7 @@ if (environment.APP_ENV == 'test') {
export class Config { export class Config {
static API_URL: string = env_var ?? environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? ''; 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 KEYCLOAK_URL: string = environment.REACT_APP_KEYCLOAK_URL ?? '';
static REALM: string = environment.REACT_APP_REALM ?? ''; static REALM: string = environment.REACT_APP_REALM ?? '';
static CLIENT_ID: string = environment.REACT_APP_CLIENT_ID ?? ''; static CLIENT_ID: string = environment.REACT_APP_CLIENT_ID ?? '';

11
web/src/env.d.ts vendored
View File

@ -1 +1,12 @@
/// <reference types="@rsbuild/core/types" /> /// <reference types="@rsbuild/core/types" />
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;
}
}