diff --git a/src/pages/User/UserCreatePage/index.tsx b/src/pages/User/UserCreatePage/index.tsx
index 2816b5b..e778016 100644
--- a/src/pages/User/UserCreatePage/index.tsx
+++ b/src/pages/User/UserCreatePage/index.tsx
@@ -1,10 +1,4 @@
-import {
- Button,
- Paper,
- TextField,
- Checkbox,
- FormControlLabel,
-} from "@mui/material";
+import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -133,26 +127,6 @@ export const UserCreatePage = observer(() => {
}
/>
-
- {
- setCreateUserData(
- createUserData.name || "",
- createUserData.email || "",
- createUserData.password || "",
- e.target.checked,
- createUserData.icon
- );
- }}
- />
- }
- label="Администратор"
- />
-
-
r !== `${resource}_ro` && r !== `${resource}_rw`,
+ );
+ if (level === "ro") return [...filtered, `${resource}_ro`];
+ if (level === "rw") return [...filtered, `${resource}_rw`];
+ return filtered;
+}
+
export const UserEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { id } = useParams();
- const { editUserData, editUser, getUser, setEditUserData } = userStore;
+ const { editUserData, editUser, getUser, setEditUserData, setEditUserRoles } = userStore;
+ const canReadCities = authStore.canRead("cities");
+
+ const [localRoles, setLocalRoles] = useState([]);
+ const [localCityIds, setLocalCityIds] = useState([]);
+ const [initialUserCities, setInitialUserCities] = useState([]);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@@ -44,13 +97,65 @@ export const UserEditPage = observer(() => {
languageStore.setLanguage("ru");
}, []);
- const handleEdit = async () => {
+ useEffect(() => {
+ (async () => {
+ if (id) {
+ setIsLoadingData(true);
+ try {
+ if (!authStore.me) {
+ await authStore.getMeAction().catch(() => undefined);
+ }
+ await Promise.all([
+ mediaStore.getMedia(),
+ authStore.canRead("cities")
+ ? cityStore.getRuCities()
+ : authStore.fetchMeCities().catch(() => undefined),
+ ]);
+ const data = (await getUser(Number(id))) as User | undefined;
+
+ if (data) {
+ setEditUserData(
+ data.name || "",
+ data.email || "",
+ data.password || "",
+ data.is_admin || false,
+ data.icon || "",
+ );
+
+ const roles = data.roles ?? [];
+ setLocalRoles(roles);
+ setEditUserRoles(roles);
+
+ const cityIds = (data.cities ?? []).map((c) => c.city_id);
+ setLocalCityIds(cityIds);
+ setInitialUserCities(data.cities ?? []);
+ }
+ } finally {
+ setIsLoadingData(false);
+ }
+ } else {
+ setIsLoadingData(false);
+ }
+ })();
+ }, [id]);
+
+ const handleSave = async () => {
try {
setIsLoading(true);
+
+ const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"];
+ const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles]));
+ setEditUserRoles(rolesToSave);
await editUser(Number(id));
- toast.success("Пользователь успешно обновлен");
+
+ await userStore.addUserCityAction({
+ id: Number(id),
+ city_ids: localCityIds,
+ });
+
+ toast.success("Пользователь успешно обновлён");
navigate("/user");
- } catch (error) {
+ } catch {
toast.error("Ошибка при обновлении пользователя");
} finally {
setIsLoading(false);
@@ -68,43 +173,43 @@ export const UserEditPage = observer(() => {
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
- media.id
+ media.id,
);
};
- useEffect(() => {
- (async () => {
- if (id) {
- setIsLoadingData(true);
- try {
- await mediaStore.getMedia();
- const data = await getUser(Number(id));
-
- if (data) {
- setEditUserData(
- data.name || "",
- data.email || "",
- data.password || "",
- data.is_admin || false,
- data.icon || ""
- );
- }
- } finally {
- setIsLoadingData(false);
- }
- } else {
- setIsLoadingData(false);
- }
- })();
- }, [id]);
-
const selectedMedia =
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
? mediaStore.media.find((m) => m.id === editUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
? null
- : selectedMedia?.id ?? editUserData.icon ?? null;
+ : (selectedMedia?.id ?? editUserData.icon ?? null);
+
+ const cityOptionsMap = new Map();
+
+ const sourceCities: UserCity[] = canReadCities
+ ? cityStore.ruCities.data
+ .filter((city) => city.id !== undefined)
+ .map((city) => ({
+ city_id: city.id as number,
+ name: city.name,
+ }))
+ : authStore.meCities.ru;
+
+ for (const city of sourceCities) {
+ cityOptionsMap.set(city.city_id, city.name);
+ }
+
+ for (const city of initialUserCities) {
+ if (!cityOptionsMap.has(city.city_id)) {
+ cityOptionsMap.set(city.city_id, city.name);
+ }
+ }
+
+ const cityOptions = Array.from(cityOptionsMap.entries()).map(([value, label]) => ({
+ value,
+ label,
+ }));
if (isLoadingData) {
return (
@@ -122,18 +227,16 @@ export const UserEditPage = observer(() => {
}
return (
-
-
-
-
+
+
+
+ {/* ── Основные данные ── */}
+
+ Основные данные
-
{
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
- editUserData.icon
+ editUserData.icon,
)
}
/>
@@ -160,11 +263,10 @@ export const UserEditPage = observer(() => {
e.target.value,
editUserData.password || "",
editUserData.is_admin || false,
- editUserData.icon
+ editUserData.icon,
)
}
/>
-
{
editUserData.email || "",
e.target.value,
editUserData.is_admin || false,
- editUserData.icon
+ editUserData.icon,
)
}
/>
-
- setEditUserData(
- editUserData.name || "",
- editUserData.email || "",
- editUserData.password || "",
- e.target.checked,
- editUserData.icon
- )
- }
- />
- }
- label="Администратор"
- />
{
}}
/>
+
- }
- onClick={handleEdit}
- disabled={isLoading || !editUserData.name || !editUserData.email}
- >
- {isLoading ? (
-
- ) : (
- "Сохранить"
- )}
-
-
+
+
+ {/* ── Права доступа ── */}
+
+ Права доступа
+
+ {
+ if (e.target.checked) {
+ setLocalRoles((prev) => {
+ let next = prev.filter((r) => r !== "admin");
+ for (const { key } of ROLE_RESOURCES) {
+ next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
+ next.push(`${key}_rw`);
+ }
+ if (!next.includes("snapshot_create")) {
+ next.push("snapshot_create");
+ }
+ next.push("admin");
+ return next;
+ });
+ } else {
+ setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
+ }
+ }}
+ />
+ }
+ label="Полный доступ (admin)"
+ />
+
+
+
+
+
+ Ресурс
+ Нет доступа
+ Чтение
+ Чтение/Запись
+
+ Создание (snapshot_create)
+
+
+
+
+ {ROLE_RESOURCES.map(({ key, label }) => {
+ const level = getPermissionLevel(localRoles, key);
+ const isSnapshotResource = key === "snapshot";
+
+ const handleChange = (val: string) => {
+ setLocalRoles((prev) => {
+ let updated = applyPermissionChange(prev, key, val as PermissionLevel);
+
+ if (key === "devices") {
+ updated = applyPermissionChange(
+ updated,
+ "vehicles",
+ val as PermissionLevel,
+ );
+ }
+
+ const allRw = ROLE_RESOURCES.every(({ key: k }) =>
+ updated.includes(`${k}_rw`),
+ );
+ if (allRw && !updated.includes("admin")) {
+ const next = [...updated];
+ if (!next.includes("snapshot_create")) {
+ next.push("snapshot_create");
+ }
+ next.push("admin");
+ return next;
+ }
+ if (!allRw) {
+ return updated.filter((r) => r !== "admin");
+ }
+ return updated;
+ });
+ };
+
+ const handleSnapshotCreateChange = (checked: boolean) => {
+ if (!isSnapshotResource) {
+ return;
+ }
+ setLocalRoles((prev) => {
+ const withoutSnapshotCreate = prev.filter(
+ (role) => role !== "snapshot_create"
+ );
+ return checked
+ ? [...withoutSnapshotCreate, "snapshot_create"]
+ : withoutSnapshotCreate;
+ });
+ };
+
+ return (
+
+ {label}
+
+ handleChange(e.target.value)}
+ sx={{ justifyContent: "center", flexWrap: "nowrap" }}
+ >
+
+
+
+
+ {isSnapshotResource ? (
+
+ -
+
+ ) : (
+ handleChange(e.target.value)}
+ sx={{ justifyContent: "center", flexWrap: "nowrap" }}
+ >
+
+
+ )}
+
+
+ handleChange(e.target.value)}
+ sx={{ justifyContent: "center", flexWrap: "nowrap" }}
+ >
+
+
+
+
+ {isSnapshotResource ? (
+
+ handleSnapshotCreateChange(e.target.checked)
+ }
+ size="small"
+ />
+ ) : (
+
+ -
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ {/* ── Города ── */}
+
+ Города
+ setLocalCityIds(ids as number[])}
+ label="Города"
+ placeholder="Выберите города"
+ loading={canReadCities ? !cityStore.ruCities.loaded : isLoadingData}
+ />
+
+
+ : }
+ onClick={handleSave}
+ disabled={isLoading || !editUserData.name || !editUserData.email}
+ >
+ Сохранить
+
{
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
-
setIsUploadMediaOpen(false)}
@@ -249,13 +501,11 @@ export const UserEditPage = observer(() => {
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
-
setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
-
{
@@ -264,7 +514,7 @@ export const UserEditPage = observer(() => {
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
- ""
+ "",
);
setIsDeleteIconModalOpen(false);
}}
diff --git a/src/pages/User/UserListPage/index.tsx b/src/pages/User/UserListPage/index.tsx
index 3ab95fa..76335b6 100644
--- a/src/pages/User/UserListPage/index.tsx
+++ b/src/pages/User/UserListPage/index.tsx
@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
-import { userStore } from "@shared";
+import { authStore, userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
@@ -20,6 +20,7 @@ export const UserListPage = observer(() => {
page: 0,
pageSize: 50,
});
+ const canWriteUsers = authStore.canWrite("users");
useEffect(() => {
const fetchUsers = async () => {
@@ -81,44 +82,35 @@ export const UserListPage = observer(() => {
},
},
- {
+ ...(canWriteUsers ? [{
field: "actions",
headerName: "Действия",
flex: 1,
- align: "center",
- headerAlign: "center",
+ align: "center" as const,
+ headerAlign: "center" as const,
sortable: false,
-
- renderCell: (params: GridRenderCellParams) => {
- return (
-
-
-
-
- );
- },
- },
+ renderCell: (params: GridRenderCellParams) => (
+
+
+
+
+ ),
+ }] : []),
];
const rows = users.data?.map((user) => ({
id: user.id,
email: user.email,
- is_admin: user.is_admin,
+ is_admin: user.is_admin || (user.roles ?? []).includes("admin"),
name: user.name,
}));
@@ -127,7 +119,9 @@ export const UserListPage = observer(() => {
Пользователи
-
+ {canWriteUsers && (
+
+ )}
{ids.length > 0 && (
@@ -145,7 +139,7 @@ export const UserListPage = observer(() => {
{
field: "actions",
headerName: "Действия",
width: 200,
- align: "center",
- headerAlign: "center",
+ align: "center" as const,
+ headerAlign: "center" as const,
sortable: false,
-
renderCell: (params: GridRenderCellParams) => {
+ const canWrite = authStore.canWrite("devices");
return (
-
+ {canWrite && (
+
+ )}
-
+ {canWrite && (
+
+ )}
);
},
@@ -167,7 +171,7 @@ export const VehicleListPage = observer(() => {
{
};
export { authInstance, languageInstance };
+export { mobxFetch } from "./mobxFetch";
diff --git a/src/shared/api/mobxFetch/index.ts b/src/shared/api/mobxFetch/index.ts
new file mode 100644
index 0000000..2ce9971
--- /dev/null
+++ b/src/shared/api/mobxFetch/index.ts
@@ -0,0 +1,183 @@
+import { runInAction } from "mobx";
+
+type mobxFetchOptions = {
+ store: Store;
+ value?: keyof Store;
+ values?: Array;
+ loading?: keyof Store;
+ error?: keyof Store;
+ fn: RequestType extends void
+ ? (signal?: AbortSignal) => Promise
+ : (request: RequestType, signal?: AbortSignal) => Promise;
+
+ pollingInterval?: number;
+ resetValue?: boolean;
+ transform?: (response: ResponseType) => Partial>;
+ onSuccess?: (response: ResponseType) => void;
+};
+
+type FetchFunction = RequestType extends void
+ ? {
+ (): Promise;
+ stopPolling?: () => void;
+ }
+ : {
+ (request: RequestType): Promise;
+ stopPolling?: () => void;
+ };
+
+export function mobxFetch>(
+ options: mobxFetchOptions
+): FetchFunction;
+
+export function mobxFetch<
+ RequestType,
+ ResponseType,
+ Store extends Record,
+>(
+ options: mobxFetchOptions
+): FetchFunction;
+
+export function mobxFetch<
+ RequestType,
+ ResponseType,
+ Store extends Record,
+>(
+ options: mobxFetchOptions
+): FetchFunction {
+ const {
+ store,
+ value,
+ values,
+ loading,
+ error,
+ fn,
+ pollingInterval,
+ resetValue,
+ transform,
+ onSuccess,
+ } = options;
+
+ let abortController: AbortController | undefined;
+ let pollingTimer: ReturnType | undefined;
+ let currentRequest: RequestType | undefined;
+
+ const stopPolling = () => {
+ if (pollingTimer) {
+ clearInterval(pollingTimer);
+ pollingTimer = undefined;
+ }
+ abortController?.abort();
+ };
+
+ const fetch = async (request?: RequestType): Promise => {
+ abortController?.abort();
+ abortController = new AbortController();
+ currentRequest = request as RequestType;
+
+ runInAction(() => {
+ if (value) {
+ (store[value] as any) = resetValue ? null : store[value];
+ }
+
+ if (values) {
+ values.forEach((key) => {
+ (store[key] as any) = resetValue ? null : store[key];
+ });
+ }
+
+ if (error) {
+ (store[error] as any) = null;
+ }
+
+ if (loading) {
+ (store[loading] as any) = true;
+ }
+ });
+
+ try {
+ const result = await (
+ fn as (
+ request?: RequestType,
+ signal?: AbortSignal
+ ) => Promise
+ )(request, abortController.signal);
+
+ runInAction(() => {
+ if (values && transform) {
+ const transformed = transform(result) as Record;
+ values.forEach((key) => {
+ const k = key as string;
+ if (k in transformed) {
+ (store[key] as any) = transformed[k];
+ }
+ });
+ } else if (value) {
+ (store[value] as any) = result as ResponseType;
+ }
+
+ if (loading) {
+ (store[loading] as any) = false;
+ }
+
+ if (error) {
+ (store[error] as any) = null;
+ }
+ });
+
+ if (pollingInterval && !pollingTimer) {
+ pollingTimer = setInterval(() => {
+ if (currentRequest !== undefined) {
+ fetch(currentRequest);
+ } else {
+ fetch();
+ }
+ }, pollingInterval);
+ }
+
+ if (onSuccess) {
+ onSuccess(result);
+ }
+
+ return result;
+ } catch (err) {
+ if (!(err instanceof Error && err.name === "CanceledError")) {
+ runInAction(() => {
+ if (error) {
+ (store[error] as any) =
+ err instanceof Error ? err.message : String(err);
+ }
+
+ if (loading) {
+ (store[loading] as any) = false;
+ }
+
+ if (value) {
+ (store[value] as any) = null;
+ }
+
+ if (values) {
+ values.forEach((key) => {
+ (store[key] as any) = null;
+ });
+ }
+ });
+
+ throw err;
+ }
+
+ return null;
+ }
+ };
+
+ const fetchWithStopPolling = fetch as FetchFunction<
+ RequestType,
+ ResponseType
+ >;
+
+ if (pollingInterval) {
+ fetchWithStopPolling.stopPolling = stopPolling;
+ }
+
+ return fetchWithStopPolling;
+}
diff --git a/src/shared/config/constants.tsx b/src/shared/config/constants.tsx
index 40eca7b..fb6e2e9 100644
--- a/src/shared/config/constants.tsx
+++ b/src/shared/config/constants.tsx
@@ -23,12 +23,63 @@ interface NavigationItem {
label: string;
icon?: LucideIcon | React.ReactNode;
path?: string;
- for_admin?: boolean;
+ requiredRoles?: string[];
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
}
+export const ROUTE_REQUIRED_RESOURCES: Record = {
+ "/": [],
+
+ "/sight": ["sights"],
+ "/sight/create": ["sights"],
+ "/sight/:id/edit": ["sights"],
+
+ "/devices": ["devices", "vehicles", "routes", "carriers", "snapshot_rw"],
+
+ "/map": ["map"],
+
+ "/media": ["sights"],
+ "/media/:id": ["sights"],
+ "/media/:id/edit": ["sights"],
+
+ "/country": ["countries"],
+ "/country/create": ["countries"],
+ "/country/add": ["countries"],
+ "/country/:id/edit": ["countries"],
+
+ "/city": ["cities", "countries"],
+ "/city/create": ["cities", "countries"],
+ "/city/:id/edit": ["cities", "countries"],
+
+ "/route": ["routes", "carriers"],
+ "/route/create": ["routes", "carriers"],
+ "/route/:id/edit": ["routes", "carriers"],
+
+ "/user": ["users"],
+ "/user/create": ["users"],
+ "/user/:id/edit": ["users"],
+
+ "/snapshot": ["snapshot_rw"],
+ "/snapshot/create": ["snapshot_create", "devices_rw"],
+
+ "/carrier": ["carriers"],
+ "/carrier/create": ["carriers"],
+ "/carrier/:id/edit": ["carriers"],
+
+ "/station": ["stations"],
+ "/station/create": ["stations"],
+ "/station/:id": ["stations"],
+ "/station/:id/edit": ["stations"],
+
+ "/vehicle/create": ["devices"],
+ "/vehicle/:id/edit": ["devices"],
+
+ "/article": ["sights"],
+ "/article/:id": ["sights"],
+};
+
export const NAVIGATION_ITEMS: {
primary: NavigationItem[];
secondary: NavigationItem[];
@@ -39,7 +90,7 @@ export const NAVIGATION_ITEMS: {
label: "Экспорт",
icon: GitBranch,
path: "/snapshot",
- for_admin: true,
+ requiredRoles: ["snapshot_rw", "snapshot_create"],
},
{
id: "map",
@@ -52,14 +103,14 @@ export const NAVIGATION_ITEMS: {
label: "Устройства",
icon: Cpu,
path: "/devices",
- for_admin: true,
+ requiredRoles: ["devices_ro", "devices_rw"],
},
{
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
- for_admin: true,
+ requiredRoles: ["users_ro", "users_rw"],
},
{
id: "all",
@@ -71,18 +122,21 @@ export const NAVIGATION_ITEMS: {
label: "Достопримечательности",
icon: Landmark,
path: "/sight",
+ requiredRoles: ["sights_ro", "sights_rw"],
},
{
id: "stations",
label: "Остановки",
icon: PersonStanding,
path: "/station",
+ requiredRoles: ["stations_ro", "stations_rw"],
},
{
id: "routes",
label: "Маршруты",
icon: Split,
path: "/route",
+ requiredRoles: ["routes_ro", "routes_rw"],
},
{
@@ -90,14 +144,14 @@ export const NAVIGATION_ITEMS: {
label: "Страны",
icon: Earth,
path: "/country",
- for_admin: true,
+ requiredRoles: ["countries_ro", "countries_rw"],
},
{
id: "cities",
label: "Города",
icon: Building2,
path: "/city",
- for_admin: true,
+ requiredRoles: ["cities_ro", "cities_rw"],
},
{
id: "carriers",
@@ -105,7 +159,7 @@ export const NAVIGATION_ITEMS: {
// @ts-ignore
icon: () =>
,
path: "/carrier",
- for_admin: true,
+ requiredRoles: ["carriers_ro", "carriers_rw"],
},
],
},
@@ -123,6 +177,20 @@ export const NAVIGATION_ITEMS: {
],
};
+function collectRoles(list: NavigationItem[]): string[] {
+ const roles = new Set(["admin"]);
+ const walk = (items: NavigationItem[]) => {
+ for (const item of items) {
+ item.requiredRoles?.forEach((r) => roles.add(r));
+ item.nestedItems && walk(item.nestedItems);
+ }
+ };
+ walk(list);
+ return Array.from(roles);
+}
+
+export const ALL_ROLES = collectRoles(NAVIGATION_ITEMS.primary);
+
export const VEHICLE_TYPES = [
{ label: "Автобус", value: 3 },
{ label: "Троллейбус", value: 2 },
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index 33db187..471ab14 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -1,6 +1,7 @@
export * from "./mui/theme";
export * from "./DecodeJWT";
export * from "./gltfCacheManager";
+export * from "./permissions";
export const generateDefaultMediaName = (
objectName: string,
diff --git a/src/shared/lib/permissions/index.ts b/src/shared/lib/permissions/index.ts
new file mode 100644
index 0000000..2d46414
--- /dev/null
+++ b/src/shared/lib/permissions/index.ts
@@ -0,0 +1,18 @@
+export const canRead = (roles: string[] | undefined, resource: string): boolean => {
+ if (!roles || roles.length === 0) return false;
+ return (
+ roles.includes("admin") ||
+ roles.includes(`${resource}_ro`) ||
+ roles.includes(`${resource}_rw`)
+ );
+};
+
+export const canWrite = (roles: string[] | undefined, resource: string): boolean => {
+ if (!roles || roles.length === 0) return false;
+ return roles.includes("admin") || roles.includes(`${resource}_rw`);
+};
+
+export const createPermissions = (roles: string[] | undefined) => ({
+ canRead: (resource: string) => canRead(roles, resource),
+ canWrite: (resource: string) => canWrite(roles, resource),
+});
diff --git a/src/shared/store/AuthStore/api.ts b/src/shared/store/AuthStore/api.ts
new file mode 100644
index 0000000..005df50
--- /dev/null
+++ b/src/shared/store/AuthStore/api.ts
@@ -0,0 +1,25 @@
+import { languageInstance } from "@shared";
+import { User, UserCity } from "../UserStore";
+
+export const getMeApi = async (): Promise => {
+ const response = await languageInstance("ru").get("/auth/me");
+ return response.data as User;
+};
+
+export const getMeCitiesApi = async (): Promise<{
+ ru: UserCity[];
+ en: UserCity[];
+ zh: UserCity[];
+}> => {
+ const [ru, en, zh] = await Promise.all([
+ languageInstance("ru").get("/auth/me"),
+ languageInstance("en").get("/auth/me"),
+ languageInstance("zh").get("/auth/me"),
+ ]);
+
+ return {
+ ru: ((ru.data as User).cities ?? []),
+ en: ((en.data as User).cities ?? []),
+ zh: ((zh.data as User).cities ?? []),
+ };
+};
diff --git a/src/shared/store/AuthStore/index.tsx b/src/shared/store/AuthStore/index.tsx
index 21cbd0f..51bdcf3 100644
--- a/src/shared/store/AuthStore/index.tsx
+++ b/src/shared/store/AuthStore/index.tsx
@@ -1,15 +1,13 @@
-import { API_URL, decodeJWT } from "@shared";
+import { API_URL, decodeJWT, mobxFetch } from "@shared";
+import { canRead as checkCanRead, canWrite as checkCanWrite } from "../../lib/permissions";
import { makeAutoObservable, runInAction } from "mobx";
import axios, { AxiosError } from "axios";
+import { User, UserCity } from "../UserStore";
+import { getMeApi, getMeCitiesApi } from "./api";
type LoginResponse = {
token: string;
- user: {
- id: number;
- name: string;
- email: string;
- is_admin: boolean;
- };
+ user: Pick;
};
class AuthStore {
@@ -48,7 +46,7 @@ class AuthStore {
{
email,
password,
- }
+ },
);
const data = response.data;
@@ -89,6 +87,78 @@ class AuthStore {
get user() {
return this.payload?.user;
}
+
+ get isAdmin(): boolean {
+ return (
+ this.me?.is_admin === true ||
+ (this.me?.roles ?? []).includes("admin")
+ );
+ }
+
+ me: User | null = null;
+ meLoading = false;
+ meError: string | null = null;
+
+ meCities: { ru: UserCity[]; en: UserCity[]; zh: UserCity[] } = {
+ ru: [],
+ en: [],
+ zh: [],
+ };
+
+ getMeAction = mobxFetch({
+ store: this,
+ value: "me",
+ loading: "meLoading",
+ error: "meError",
+ fn: getMeApi,
+ onSuccess: () => {
+ this.fetchMeCities();
+ },
+ });
+
+ fetchMeCities = async () => {
+ const cities = await getMeCitiesApi();
+ runInAction(() => {
+ this.meCities = cities;
+ });
+ };
+
+ canWrite = (resource: string): boolean => {
+ const roles = this.me?.roles ?? [];
+
+ if (roles.includes("admin")) {
+ return true;
+ }
+
+ if (resource === "map") {
+ return roles.some((role) =>
+ ["routes_rw", "stations_rw", "sights_rw"].includes(role),
+ );
+ }
+
+ return checkCanWrite(roles, resource);
+ };
+
+ hasRole = (role: string): boolean => {
+ const roles = this.me?.roles ?? [];
+ return roles.includes("admin") || roles.includes(role);
+ };
+
+ canRead = (resource: string): boolean => {
+ if (resource === "map") {
+ return this.canWrite("map");
+ }
+ return checkCanRead(this.me?.roles, resource);
+ };
+
+ canAccess = (permission: string): boolean => {
+ // If permission looks like a concrete role (e.g. snapshot_create/snapshot_rw),
+ // check it as-is; otherwise treat it as a resource name.
+ if (permission.includes("_")) {
+ return this.hasRole(permission);
+ }
+ return this.canRead(permission);
+ };
}
export const authStore = new AuthStore();
diff --git a/src/shared/store/CarrierStore/index.tsx b/src/shared/store/CarrierStore/index.tsx
index f0886ba..e435685 100644
--- a/src/shared/store/CarrierStore/index.tsx
+++ b/src/shared/store/CarrierStore/index.tsx
@@ -1,5 +1,6 @@
import {
authInstance,
+ authStore,
cityStore,
languageStore,
languageInstance,
@@ -145,12 +146,51 @@ class CarrierStore {
};
};
+ private resolveCityName = (cityId: number, preferredLanguage: Language) => {
+ if (!cityId) {
+ return "";
+ }
+
+ const languages: Language[] = ["ru", "en", "zh"];
+
+ const fromCityStorePreferred = cityStore.cities[preferredLanguage].data.find(
+ (city) => city.id === cityId
+ )?.name;
+ if (fromCityStorePreferred) {
+ return fromCityStorePreferred;
+ }
+
+ for (const language of languages) {
+ const cityName = cityStore.cities[language].data.find(
+ (city) => city.id === cityId
+ )?.name;
+ if (cityName) {
+ return cityName;
+ }
+ }
+
+ const fromMePreferred = authStore.meCities[preferredLanguage].find(
+ (city) => city.city_id === cityId
+ )?.name;
+ if (fromMePreferred) {
+ return fromMePreferred;
+ }
+
+ for (const language of languages) {
+ const cityName = authStore.meCities[language].find(
+ (city) => city.city_id === cityId
+ )?.name;
+ if (cityName) {
+ return cityName;
+ }
+ }
+
+ return "";
+ };
+
createCarrier = async () => {
const { language } = languageStore;
- const cityName =
- cityStore.cities[language].data.find(
- (city) => city.id === this.createCarrierData.city_id
- )?.name || "";
+ const cityName = this.resolveCityName(this.createCarrierData.city_id, language);
const payload = {
full_name: this.createCarrierData[language].full_name,
@@ -172,12 +212,16 @@ class CarrierStore {
});
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
+ const cityNameForLang = this.resolveCityName(
+ this.createCarrierData.city_id,
+ lang as Language
+ );
const patchPayload = {
// @ts-ignore
full_name: this.createCarrierData[lang as any].full_name as string,
// @ts-ignore
short_name: this.createCarrierData[lang as any].short_name as string,
- city: cityName,
+ city: cityNameForLang || cityName,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: this.createCarrierData[lang as any].slogan as string,
@@ -273,13 +317,8 @@ class CarrierStore {
};
editCarrier = async (id: number) => {
- const { language } = languageStore;
- const cityName =
- cityStore.cities[language].data.find(
- (city) => city.id === this.editCarrierData.city_id
- )?.name || "";
-
for (const lang of ["ru", "en", "zh"] as const) {
+ const cityName = this.resolveCityName(this.editCarrierData.city_id, lang);
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
...this.editCarrierData[lang],
city: cityName,
diff --git a/src/shared/store/UserStore/api.ts b/src/shared/store/UserStore/api.ts
new file mode 100644
index 0000000..d2fb412
--- /dev/null
+++ b/src/shared/store/UserStore/api.ts
@@ -0,0 +1,14 @@
+import { authInstance } from "@shared";
+import { User } from "./index";
+
+export const addUserCityApi = async (
+ { id, city_ids }: { id: number; city_ids: number[] },
+ signal?: AbortSignal,
+): Promise => {
+ const response = await authInstance.patch(
+ `/user/${id}/city`,
+ { city_ids },
+ { signal },
+ );
+ return response.data as User;
+};
diff --git a/src/shared/store/UserStore/index.ts b/src/shared/store/UserStore/index.ts
index c748337..7e43248 100644
--- a/src/shared/store/UserStore/index.ts
+++ b/src/shared/store/UserStore/index.ts
@@ -1,5 +1,11 @@
-import { authInstance } from "@shared";
+import { authInstance, mobxFetch } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
+import { addUserCityApi } from "./api";
+
+export type UserCity = {
+ city_id: number;
+ name: string;
+};
export type User = {
id: number;
@@ -8,6 +14,8 @@ export type User = {
name: string;
password?: string;
icon?: string;
+ roles?: string[];
+ cities?: UserCity[];
};
class UserStore {
@@ -59,6 +67,7 @@ class UserStore {
password: "",
is_admin: false,
icon: "",
+ roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"],
};
setCreateUserData = (
@@ -66,9 +75,10 @@ class UserStore {
email: string,
password: string,
is_admin: boolean,
- icon?: string
+ icon?: string,
) => {
this.createUserData = {
+ ...this.createUserData,
name,
email,
password,
@@ -82,7 +92,13 @@ class UserStore {
if (this.users.data.length > 0) {
id = this.users.data[this.users.data.length - 1].id + 1;
}
- const payload = { ...this.createUserData };
+ const payload: Partial = { ...this.createUserData };
+ const baseRoles = new Set(payload.roles ?? []);
+ baseRoles.add("articles_ro");
+ baseRoles.add("articles_rw");
+ baseRoles.add("media_ro");
+ baseRoles.add("media_rw");
+ payload.roles = Array.from(baseRoles);
if (!payload.icon) delete payload.icon;
const response = await authInstance.post("/user", payload);
@@ -100,6 +116,7 @@ class UserStore {
password: "",
is_admin: false,
icon: "",
+ roles: [],
};
setEditUserData = (
@@ -107,9 +124,10 @@ class UserStore {
email: string,
password: string,
is_admin: boolean,
- icon?: string
+ icon?: string,
) => {
this.editUserData = {
+ ...this.editUserData,
name,
email,
password,
@@ -118,19 +136,50 @@ class UserStore {
};
};
+ setEditUserRoles = (roles: string[]) => {
+ this.editUserData = { ...this.editUserData, roles };
+ };
+
editUser = async (id: number) => {
const payload = { ...this.editUserData };
if (!payload.icon) delete payload.icon;
if (!payload.password?.trim()) delete payload.password;
+
const response = await authInstance.patch(`/user/${id}`, payload);
runInAction(() => {
this.users.data = this.users.data.map((user) =>
- user.id === id ? { ...user, ...response.data } : user
+ user.id === id ? { ...user, ...response.data } : user,
);
this.user[id] = { ...this.user[id], ...response.data };
});
};
+
+ addUserCityResult: User | null = null;
+ addUserCityLoading = false;
+ addUserCityError: string | null = null;
+
+ addUserCityAction = mobxFetch<
+ { id: number; city_ids: number[] },
+ User,
+ UserStore
+ >({
+ store: this,
+ value: "addUserCityResult",
+ loading: "addUserCityLoading",
+ error: "addUserCityError",
+ fn: addUserCityApi,
+ onSuccess: (result) => {
+ runInAction(() => {
+ this.users.data = this.users.data.map((user) =>
+ user.id === result.id ? { ...user, ...result } : user,
+ );
+ if (this.user[result.id]) {
+ this.user[result.id] = { ...this.user[result.id], ...result };
+ }
+ });
+ },
+ });
}
export const userStore = new UserStore();
diff --git a/src/shared/store/VehicleStore/api.ts b/src/shared/store/VehicleStore/api.ts
new file mode 100644
index 0000000..25aa22f
--- /dev/null
+++ b/src/shared/store/VehicleStore/api.ts
@@ -0,0 +1,13 @@
+import { languageInstance } from "@shared";
+import { VehicleMaintenanceSession } from "./types";
+
+export const getVehicleSessionsApi = async (
+ id: number,
+ signal?: AbortSignal,
+): Promise => {
+ const response = await languageInstance("ru").get(`/vehicle/${id}/sessions`, {
+ signal,
+ });
+
+ return Array.isArray(response.data) ? response.data : [];
+};
diff --git a/src/shared/store/VehicleStore/index.ts b/src/shared/store/VehicleStore/index.ts
index 0c9b7cb..525829e 100644
--- a/src/shared/store/VehicleStore/index.ts
+++ b/src/shared/store/VehicleStore/index.ts
@@ -1,30 +1,9 @@
-import { authInstance, languageInstance } from "@shared";
+import { authInstance, languageInstance, mobxFetch } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
+import { getVehicleSessionsApi } from "./api";
+import { Vehicle, VehicleMaintenanceSession } from "./types";
-export type Vehicle = {
- vehicle: {
- id: number;
- tail_number: string;
- type: number;
- carrier_id: number;
- carrier: string;
- uuid?: string;
- model?: string;
- current_snapshot_uuid?: string;
- snapshot_update_blocked?: boolean;
- demo_mode_enabled?: boolean;
- maintenance_mode_on?: boolean;
- city_id?: number;
- };
- device_status?: {
- device_uuid: string;
- online: boolean;
- gps_ok: boolean;
- media_service_ok: boolean;
- last_update: string;
- is_connected: boolean;
- };
-};
+export type { Vehicle, VehicleMaintenanceSession } from "./types";
class VehicleStore {
vehicles: {
@@ -35,6 +14,9 @@ class VehicleStore {
loaded: false,
};
vehicle: Record = {};
+ vehicleSessions: VehicleMaintenanceSession[] | null = null;
+ vehicleSessionsLoading = false;
+ vehicleSessionsError: string | null = null;
constructor() {
makeAutoObservable(this);
@@ -89,7 +71,7 @@ class VehicleStore {
if (updatedUuid != null) {
const entry = Object.entries(this.vehicle).find(
- ([, item]) => item.vehicle.uuid === updatedUuid
+ ([, item]) => item.vehicle.uuid === updatedUuid,
);
if (entry) {
@@ -118,7 +100,7 @@ class VehicleStore {
runInAction(() => {
this.vehicles.data = this.vehicles.data.filter(
- (vehicle) => vehicle.vehicle.id !== id
+ (vehicle) => vehicle.vehicle.id !== id,
);
});
};
@@ -137,7 +119,7 @@ class VehicleStore {
type: number,
carrier: string,
carrierId: number,
- model?: string
+ model?: string,
) => {
const payload: Record = {
tail_number: tailNumber,
@@ -197,7 +179,7 @@ class VehicleStore {
carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
- }
+ },
) => {
const payload: Record = {
tail_number: data.tail_number,
@@ -210,7 +192,7 @@ class VehicleStore {
payload.snapshot_update_blocked = data.snapshot_update_blocked;
const response = await languageInstance("ru").patch(
`/vehicle/${id}`,
- payload
+ payload,
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
const updatedVehiclePayload = {
@@ -230,9 +212,12 @@ class VehicleStore {
};
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
- const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
- enabled,
- });
+ const response = await authInstance.post(
+ `/devices/${uuid}/maintenance-mode`,
+ {
+ enabled,
+ },
+ );
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
@@ -255,10 +240,24 @@ class VehicleStore {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
- demo_mode_enabled: normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
+ demo_mode_enabled:
+ normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
});
});
};
+
+ getVehicleSessions = mobxFetch<
+ number,
+ VehicleMaintenanceSession[],
+ VehicleStore
+ >({
+ store: this,
+ value: "vehicleSessions",
+ loading: "vehicleSessionsLoading",
+ error: "vehicleSessionsError",
+ resetValue: true,
+ fn: getVehicleSessionsApi,
+ });
}
export const vehicleStore = new VehicleStore();
diff --git a/src/shared/store/VehicleStore/types.ts b/src/shared/store/VehicleStore/types.ts
new file mode 100644
index 0000000..11bd39e
--- /dev/null
+++ b/src/shared/store/VehicleStore/types.ts
@@ -0,0 +1,33 @@
+export type Vehicle = {
+ vehicle: {
+ id: number;
+ tail_number: string;
+ type: number;
+ carrier_id: number;
+ carrier: string;
+ uuid?: string;
+ model?: string;
+ current_snapshot_uuid?: string;
+ snapshot_update_blocked?: boolean;
+ demo_mode_enabled?: boolean;
+ maintenance_mode_on?: boolean;
+ city_id?: number;
+ };
+ device_status?: {
+ device_uuid: string;
+ online: boolean;
+ gps_ok: boolean;
+ media_service_ok: boolean;
+ last_update: string;
+ is_connected: boolean;
+ current_route_id?: number;
+ };
+};
+
+export type VehicleMaintenanceSession = {
+ duration_seconds: number;
+ ended_at: string;
+ id: number;
+ started_at: string;
+ vehicle_id: number;
+};
diff --git a/src/shared/ui/MultiSelect/index.tsx b/src/shared/ui/MultiSelect/index.tsx
new file mode 100644
index 0000000..d8016e3
--- /dev/null
+++ b/src/shared/ui/MultiSelect/index.tsx
@@ -0,0 +1,95 @@
+import {
+ Autocomplete,
+ Checkbox,
+ CircularProgress,
+ TextField,
+} from "@mui/material";
+import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
+import CheckBoxIcon from "@mui/icons-material/CheckBox";
+
+export interface MultiSelectOption {
+ readonly value: TValue;
+ readonly label: string;
+}
+
+interface MultiSelectProps {
+ readonly options: MultiSelectOption[];
+ readonly value: TValue[];
+ readonly onChange: (values: TValue[]) => void;
+ readonly label?: string;
+ readonly placeholder?: string;
+ readonly loading?: boolean;
+ readonly disabled?: boolean;
+ readonly error?: boolean;
+ readonly helperText?: string;
+ readonly size?: "small" | "medium";
+ readonly fullWidth?: boolean;
+}
+
+export function MultiSelect({
+ options,
+ value,
+ onChange,
+ label,
+ placeholder,
+ loading = false,
+ disabled = false,
+ error = false,
+ helperText,
+ size = "small",
+ fullWidth = true,
+}: MultiSelectProps) {
+ const selectedOptions = options.filter((opt) => value.includes(opt.value));
+
+ return (
+ option.label}
+ isOptionEqualToValue={(option, selected) => option.value === selected.value}
+ onChange={(_, newSelected) => {
+ onChange(newSelected.map((opt) => opt.value));
+ }}
+ renderOption={(props, option, { selected }) => {
+ const { key, ...rest } = props as React.HTMLAttributes & { key: React.Key };
+ return (
+
+ }
+ checkedIcon={}
+ style={{ marginRight: 8 }}
+ checked={selected}
+ />
+ {option.label}
+
+ );
+ }}
+ renderInput={(params) => (
+
+ {loading && }
+ {params.InputProps.endAdornment}
+ >
+ ),
+ },
+ }}
+ />
+ )}
+ />
+ );
+}
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 1adb123..e0e28d8 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -4,3 +4,4 @@ export * from "./Modal";
export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";
+export * from "./MultiSelect";
diff --git a/src/widgets/CitySelector/index.tsx b/src/widgets/CitySelector/index.tsx
index 6ac2771..7f08aab 100644
--- a/src/widgets/CitySelector/index.tsx
+++ b/src/widgets/CitySelector/index.tsx
@@ -8,16 +8,40 @@ import {
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
-import { cityStore, selectedCityStore } from "@shared";
+import { authStore, cityStore, selectedCityStore, type City } from "@shared";
import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => {
- const { getCities, cities } = cityStore;
const { selectedCity, setSelectedCity } = selectedCityStore;
+ const canReadCities = authStore.canRead("cities");
useEffect(() => {
- getCities("ru");
- }, []);
+ if (canReadCities) {
+ cityStore.getCities("ru");
+ return;
+ }
+ authStore.fetchMeCities().catch(() => undefined);
+ }, [canReadCities]);
+
+ const baseCities: City[] = canReadCities
+ ? cityStore.cities["ru"].data
+ : authStore.meCities["ru"].map((uc) => ({
+ id: uc.city_id,
+ name: uc.name,
+ country: "",
+ country_code: "",
+ arms: "",
+ }));
+
+ const currentCities: City[] = selectedCity?.id
+ ? (() => {
+ const exists = baseCities.some((city) => city.id === selectedCity.id);
+ if (exists) {
+ return baseCities;
+ }
+ return [selectedCity, ...baseCities];
+ })()
+ : baseCities;
const handleCityChange = (event: SelectChangeEvent) => {
const cityId = event.target.value;
@@ -26,14 +50,12 @@ export const CitySelector: React.FC = observer(() => {
return;
}
- const city = cities["ru"].data.find((c) => c.id === Number(cityId));
+ const city = currentCities.find((c) => c.id === Number(cityId));
if (city) {
setSelectedCity(city);
}
};
- const currentCities = cities["ru"].data;
-
return (
@@ -51,16 +73,13 @@ export const CitySelector: React.FC = observer(() => {
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
- "&.Mui-focused .MuiOutlinedInput-notchedOutline": {
+ "&.Mui.focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
- MenuProps={{
- PaperProps: {},
- }}
>
);
@@ -404,10 +479,12 @@ export const DevicesTable = observer(() => {
>
handleToggleDemoMode(rowData)}
+ onChange={() => openDemoConfirm(rowData)}
/>
);
@@ -436,6 +513,20 @@ export const DevicesTable = observer(() => {
return snapshot?.Name ?? uuid;
},
},
+ {
+ field: "current_route",
+ headerName: "Текущий маршрут",
+ flex: 1,
+ minWidth: 140,
+ filterable: true,
+ valueGetter: (_value, row) => {
+ const rowData = row as RowData;
+ const routeId = rowData.current_route_id;
+ if (!routeId) return "—";
+ const route = routes.data.find((r) => r.id === routeId);
+ return route?.route_number || "—";
+ },
+ },
{
field: "gps",
headerName: "GPS",
@@ -496,15 +587,17 @@ export const DevicesTable = observer(() => {
justifyContent: "center",
}}
>
-
+ {canWriteDevices && (
+
+ )}
+