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:
5
client/.env.example
Normal file
5
client/.env.example
Normal 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
|
||||
|
||||
10
client/package-lock.json
generated
10
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: 'Виды оборудования' }}
|
||||
|
||||
45
client/src/auth/authProvider.ts
Normal file
45
client/src/auth/authProvider.ts
Normal 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;
|
||||
|
||||
96
client/src/auth/keycloak.ts
Normal file
96
client/src/auth/keycloak.ts
Normal 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
24
client/src/config/env.ts
Normal 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
client/src/vite-env.d.ts
vendored
11
client/src/vite-env.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user