keycloak init

This commit is contained in:
MaKarin
2026-03-21 16:00:27 +03:00
parent 33521016d3
commit 8d6875f4b0
50 changed files with 2242 additions and 252 deletions

View File

@@ -1,5 +1,6 @@
import { Admin, Resource } from 'react-admin';
import dataProvider from './dataProvider';
import authProvider from './auth/authProvider';
import { EquipmentTypeList } from './resources/equipment-type/EquipmentTypeList';
import { EquipmentTypeCreate } from './resources/equipment-type/EquipmentTypeCreate';
@@ -17,7 +18,7 @@ import { RepairOrderEdit } from './resources/repair-order/RepairOrderEdit';
import { RepairOrderShow } from './resources/repair-order/RepairOrderShow';
const App = () => (
<Admin dataProvider={dataProvider}>
<Admin dataProvider={dataProvider} authProvider={authProvider} requireAuth>
<Resource
name="equipment-types"
options={{ label: 'Виды оборудования' }}

View File

@@ -0,0 +1,45 @@
import { AuthProvider } from 'react-admin';
import {
forceReauthentication,
getIdentity,
getRealmRoles,
getValidAccessToken,
initKeycloak,
logoutFromKeycloak,
} from './keycloak';
const authProvider: AuthProvider = {
login: async () => {
await initKeycloak();
},
logout: async () => {
await logoutFromKeycloak();
},
checkAuth: async () => {
await getValidAccessToken();
},
checkError: async (error) => {
const status = error?.status;
if (status === 401) {
await forceReauthentication();
return Promise.reject(error);
}
if (status === 403) {
return Promise.resolve();
}
return Promise.resolve();
},
getIdentity: async () => getIdentity(),
getPermissions: async () => getRealmRoles(),
};
export default authProvider;

View File

@@ -0,0 +1,96 @@
import Keycloak, { KeycloakTokenParsed } from 'keycloak-js';
import { env } from '../config/env';
interface RealmAccessTokenParsed extends KeycloakTokenParsed {
realm_access?: {
roles: string[];
};
}
const keycloak = new Keycloak({
url: env.keycloakUrl,
realm: env.keycloakRealm,
clientId: env.keycloakClientId,
});
let keycloakInitPromise: Promise<void> | null = null;
let refreshInFlight: Promise<void> | null = null;
export function getKeycloak() {
return keycloak;
}
export async function initKeycloak() {
if (!keycloakInitPromise) {
keycloakInitPromise = keycloak
.init({
onLoad: 'login-required',
pkceMethod: 'S256',
checkLoginIframe: false,
})
.then((authenticated) => {
if (!authenticated) {
return keycloak.login({ redirectUri: window.location.href });
}
});
}
await keycloakInitPromise;
}
async function refreshAccessToken(minValiditySeconds = 30) {
if (!refreshInFlight) {
refreshInFlight = keycloak
.updateToken(minValiditySeconds)
.then(() => undefined)
.finally(() => {
refreshInFlight = null;
});
}
await refreshInFlight;
}
export async function getValidAccessToken(minValiditySeconds = 30): Promise<string> {
await initKeycloak();
if (!keycloak.authenticated) {
await keycloak.login({ redirectUri: window.location.href });
throw new Error('User is not authenticated');
}
await refreshAccessToken(minValiditySeconds);
if (!keycloak.token) {
throw new Error('Missing access token');
}
return keycloak.token;
}
export async function forceReauthentication() {
keycloak.clearToken();
await keycloak.login({ redirectUri: window.location.href });
}
export async function logoutFromKeycloak() {
await keycloak.logout({ redirectUri: window.location.origin });
}
export function getRealmRoles(): string[] {
const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined;
const roles = parsed?.realm_access?.roles;
return Array.isArray(roles) ? roles : [];
}
export function getIdentity() {
const parsed = keycloak.tokenParsed as RealmAccessTokenParsed | undefined;
const id = parsed?.sub ?? 'unknown';
const fullName =
parsed?.name ??
parsed?.preferred_username ??
parsed?.email ??
'Unknown User';
return { id, fullName };
}

24
client/src/config/env.ts Normal file
View File

@@ -0,0 +1,24 @@
const REQUIRED_ENV_KEYS = [
'VITE_API_URL',
'VITE_KEYCLOAK_URL',
'VITE_KEYCLOAK_REALM',
'VITE_KEYCLOAK_CLIENT_ID',
] as const;
type RequiredEnvKey = (typeof REQUIRED_ENV_KEYS)[number];
function readRequiredEnv(key: RequiredEnvKey): string {
const value = import.meta.env[key];
if (!value || !value.trim()) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const env = {
apiUrl: readRequiredEnv('VITE_API_URL'),
keycloakUrl: readRequiredEnv('VITE_KEYCLOAK_URL'),
keycloakRealm: readRequiredEnv('VITE_KEYCLOAK_REALM'),
keycloakClientId: readRequiredEnv('VITE_KEYCLOAK_CLIENT_ID'),
} as const;

View File

@@ -1,7 +1,19 @@
import { DataProvider, fetchUtils } from 'react-admin';
import { getValidAccessToken } from './auth/keycloak';
import { env } from './config/env';
const apiUrl = 'http://localhost:3000';
const httpClient = fetchUtils.fetchJson;
const apiUrl = env.apiUrl;
const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
const token = await getValidAccessToken();
const headers = new Headers(options.headers ?? { Accept: 'application/json' });
headers.set('Authorization', `Bearer ${token}`);
return fetchUtils.fetchJson(url, {
...options,
headers,
});
};
const dataProvider: DataProvider = {
getList: async (resource, params) => {

View File

@@ -1,9 +1,26 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { initKeycloak } from './auth/keycloak';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
const root = ReactDOM.createRoot(document.getElementById('root')!);
async function bootstrap() {
await initKeycloak();
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
bootstrap().catch((error) => {
console.error('Failed to initialize authentication', error);
root.render(
<React.StrictMode>
<div>Authentication initialization failed. Check your environment variables.</div>
</React.StrictMode>,
);
});

View File

@@ -1 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_KEYCLOAK_URL: string;
readonly VITE_KEYCLOAK_REALM: string;
readonly VITE_KEYCLOAK_CLIENT_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}