Merge feat/keycloak into add_filters with manual conflict resolution.

Preserve Keycloak auth/RBAC contracts while retaining filter behavior and generator consistency for future regenerations.

Made-with: Cursor
This commit is contained in:
MaKarin
2026-03-24 13:52:20 +03:00
78 changed files with 2949 additions and 3880 deletions

5
client/.env.example Normal file
View File

@@ -0,0 +1,5 @@
VITE_API_URL=http://localhost:3000
VITE_KEYCLOAK_URL=https://sso.greact.ru
VITE_KEYCLOAK_REALM=toir
VITE_KEYCLOAK_CLIENT_ID=toir-frontend

View File

@@ -11,6 +11,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.9",
"keycloak-js": "^26.2.3",
"ra-data-simple-rest": "^5.14.4",
"react": "^18.2.0",
"react-admin": "^5.14.4",
@@ -3533,6 +3534,15 @@
"jsonexport": "bin/jsonexport.js"
}
},
"node_modules/keycloak-js": {
"version": "26.2.3",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.3.tgz",
"integrity": "sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==",
"license": "Apache-2.0",
"workspaces": [
"test"
]
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
@@ -13,6 +13,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.9",
"keycloak-js": "^26.2.3",
"ra-data-simple-rest": "^5.14.4",
"react": "^18.2.0",
"react-admin": "^5.14.4",

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,
});
};
function buildQueryString(query: Record<string, unknown>) {
const search = new URLSearchParams();

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

@@ -13,60 +13,27 @@ import {
ReferenceField,
SelectArrayInput,
ReferenceInput,
AutocompleteInput,
} from "react-admin";
AutocompleteInput
} from 'react-admin';
const statusChoices = [
{ id: "Active", name: "В эксплуатации" },
{ id: "Repair", name: "В ремонте" },
{ id: "Reserve", name: "В резерве" },
{ id: "WriteOff", name: "Списано" },
{ id: 'Active', name: 'В эксплуатации' },
{ id: 'Repair', name: 'В ремонте' },
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
];
const equipmentFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput
key="inventoryNumber"
source="inventoryNumber"
label="Инвентарный номер"
/>,
<TextInput
key="serialNumber"
source="serialNumber"
label="Заводской (серийный) номер"
/>,
<TextInput
key="name"
source="name"
label="Наименование единицы оборудования"
/>,
<ReferenceInput
key="equipmentTypeCode"
source="equipmentTypeCode"
reference="equipment-types"
label="Вид оборудования"
>
<AutocompleteInput
optionText={(record) =>
record.code
? `${record.code}${record.name ?? record.code}`
: (record.name ?? record.id)
}
filterToQuery={(searchText) => ({ q: searchText })}
/>
<TextInput key="inventoryNumber" source="inventoryNumber" label="Инвентарный номер" />,
<TextInput key="serialNumber" source="serialNumber" label="Заводской (серийный) номер" />,
<TextInput key="name" source="name" label="Наименование единицы оборудования" />,
<ReferenceInput key="equipmentTypeCode" source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования">
<AutocompleteInput optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>,
<SelectArrayInput
key="status"
source="status"
label="Текущий статус"
choices={statusChoices}
/>,
<TextInput
key="location"
source="location"
label="Место эксплуатации / скважина / куст"
/>,
<TextInput key="notes" source="notes" label="Примечания" />,
<SelectArrayInput key="status" source="status" label="Текущий статус" choices={statusChoices} />,
<TextInput key="location" source="location" label="Место эксплуатации / скважина / куст" />,
<TextInput key="notes" source="notes" label="Примечания" />
];
const EquipmentListActions = () => (
@@ -78,42 +45,20 @@ const EquipmentListActions = () => (
);
export const EquipmentList = () => (
<List
actions={<EquipmentListActions />}
filters={equipmentFilters}
sort={{ field: "inventoryNumber", order: "ASC" }}
>
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" />
<ReferenceField
source="equipmentTypeCode"
reference="equipment-types"
label="Вид оборудования"
link="show"
>
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="code" />
</ReferenceField>
<SelectField
source="status"
label="Текущий статус"
choices={statusChoices}
/>
<TextField
source="location"
label="Место эксплуатации / скважина / куст"
/>
<SelectField source="status" label="Текущий статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации / скважина / куст" />
<DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberField
source="totalEngineHours"
label="Общая наработка, моточасов"
/>
<NumberField
source="engineHoursSinceLastRepair"
label="Наработка с последнего ремонта, моточасов"
/>
<NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateField source="lastRepairAt" label="Дата последнего ремонта" />
<TextField source="notes" label="Примечания" />
</Datagrid>

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;
}