fix(site-finder): вернуть доступ к профилям весов — владелец из сессии (#2782) (#2788)
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m43s
Deploy / deploy (push) Successful in 1m23s

This commit is contained in:
bot-backend 2026-08-07 10:22:55 +00:00
parent c3a35a31d4
commit 209e4e145f
5 changed files with 238 additions and 115 deletions

View file

@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab";
import { LandTab } from "@/components/site-finder/LandTab";
import { MarketTab } from "@/components/site-finder/MarketTab";
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import { useCustomPois } from "@/hooks/useCustomPois";
@ -22,6 +22,7 @@ import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
} from "@/lib/api/weightProfiles";
import { useMe } from "@/lib/useMe";
// SiteMap imports Leaflet which requires browser APIs — load without SSR
const SiteMap = dynamic(
@ -129,16 +130,16 @@ function SiteFinderContent() {
// Ref to skip the initial mount effect (we only re-analyze on actual changes).
const weightsChangeInitializedRef = useRef(false);
const [profileUserId, setProfileUserId] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_user_id") ?? ""),
);
const [adminToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
// Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения
// из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`,
// который сервер не читает с #437) — оба недостижимы без DevTools.
//
// 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без
// `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом
// `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без
// user_id → tram_stop 0.5 вместо 0.4 из профиля). То есть выбранный профиль
// молча не применялся бы, а UI показывал бы его ползунки.
const profileUserId = useMe().data?.username ?? "";
// Lazy init: считаем initialTab один раз на mount (useState всё равно
// игнорирует initializer после первого render — не тратим CPU).
const [tab, setTabState] = useState<TabId>(() => {
@ -192,12 +193,7 @@ function SiteFinderContent() {
setIsochrones(undefined);
mutate({
cad: currentData.cad_num,
options:
profileId != null
? { profileId }
: currentProfileUserId
? { profileUserId: currentProfileUserId, weights }
: { weights },
options: buildAnalyzeOptions(weights, profileId, currentProfileUserId),
});
// mutate is stable from useMutation — safe to omit from deps.
// data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num
@ -216,12 +212,11 @@ function SiteFinderContent() {
// slider values are always respected even without a saved profile (#201).
mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId, weights: currentWeights }
: { weights: currentWeights },
options: buildAnalyzeOptions(
currentWeights,
activeProfileId,
profileUserId,
),
});
}
@ -329,46 +324,9 @@ function SiteFinderContent() {
{/* Weight profile panel — collapsible, below header */}
<div style={{ marginBottom: 16 }}>
{/* Optional user-id field for profile CRUD (shown only when adminToken present) */}
{!!adminToken && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
>
User ID (для профилей):
</label>
<input
type="text"
value={profileUserId}
placeholder="user-abc"
style={{
padding: "4px 8px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 6,
width: 180,
}}
onChange={(e) => {
setProfileUserId(e.target.value);
if (typeof window !== "undefined") {
localStorage.setItem("admin_user_id", e.target.value);
}
}}
/>
</div>
)}
<WeightProfilePanel
currentWeights={currentWeights}
onWeightsChange={handleWeightsChange}
userId={profileUserId || undefined}
adminToken={adminToken || undefined}
/>
{/* Recalculation indicator shown while re-analyze is in-flight after
weights change (data already loaded, pendingWeightsChange set). */}

View file

@ -3,6 +3,7 @@
import { useCallback, useState } from "react";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { useMe } from "@/lib/useMe";
import {
POI_CATEGORIES,
POI_DEFAULT_WEIGHTS,
@ -29,13 +30,6 @@ interface Props {
weights: Record<PoiCategoryKey, number>,
profileId: number | null,
) => void;
/**
* If provided, enables save/load from DB.
* Must be non-empty for CRUD functionality.
*/
userId?: string;
/** Admin token for CRUD API calls. */
adminToken?: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -66,12 +60,7 @@ function weightsEqual(
// ── Component ─────────────────────────────────────────────────────────────────
export function WeightProfilePanel({
currentWeights,
onWeightsChange,
userId,
adminToken,
}: Props) {
export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) {
const [open, setOpen] = useState(false);
// Local draft weights — editable before "Применить"
@ -89,11 +78,19 @@ export function WeightProfilePanel({
const [saveName, setSaveName] = useState("");
const [saveDefault, setSaveDefault] = useState(false);
// Profiles query (only when userId + adminToken provided)
const canUseCrud = !!userId && !!adminToken;
const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? "");
// Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили
// руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было
// взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в
// #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут
// RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется).
// В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто
// бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен.
const { data: me } = useMe();
const userId = me?.username ?? "";
const canUseCrud = !!userId;
const profilesQuery = useWeightProfiles(userId);
const createMutation = useCreateProfile(adminToken ?? "");
const createMutation = useCreateProfile();
// ── Handlers ────────────────────────────────────────────────────────────────
@ -117,7 +114,7 @@ export function WeightProfilePanel({
}
const handleSaveProfile = useCallback(async () => {
if (!canUseCrud || !userId || !adminToken) {
if (!canUseCrud || !userId) {
setShowSaveDialog(false);
return;
}
@ -136,15 +133,7 @@ export function WeightProfilePanel({
} catch {
// Error visible through createMutation.error
}
}, [
canUseCrud,
userId,
adminToken,
saveName,
draft,
saveDefault,
createMutation,
]);
}, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]);
// ── Derived ─────────────────────────────────────────────────────────────────
@ -242,6 +231,7 @@ export function WeightProfilePanel({
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<label
style={{ fontSize: 12, color: "#6b7280", whiteSpace: "nowrap" }}
title={`Профили сохраняются отдельно для каждого пользователя. Ваш: ${userId}`}
>
Профиль:
</label>
@ -278,10 +268,13 @@ export function WeightProfilePanel({
</div>
)}
{/* Hint when no crud */}
{/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт
Caddy basic_auth) остаётся для dev-запуска без прокси, где
/api/v1/me отвечает 401. Текст называет причину, а не действие:
вводить тут больше нечего. */}
{!canUseCrud && (
<p style={{ fontSize: 11, color: "#9ca3af", margin: 0 }}>
Укажите User ID и Admin Token для сохранения профилей.
Пользователь не определён сохранение профилей недоступно.
</p>
)}
@ -362,6 +355,9 @@ export function WeightProfilePanel({
}}
>
<SectionLabel>Новый профиль</SectionLabel>
<p style={{ fontSize: 11, color: "#6b7280", margin: 0 }}>
Сохранится для пользователя {userId} другие его не увидят.
</p>
<input
autoFocus
type="text"

View file

@ -0,0 +1,151 @@
/**
* #2782 CRUD профилей весов был недостижим никому без DevTools.
*
* Панель отпирала сохранение/загрузку профилей значением `admin_token` из
* localStorage. Записывать его было нечем: инпут удалили в #442. Сервер этот
* заголовок к тому моменту уже не читал (#437, остатки убраны в #2775) то есть
* фича стояла за признаком, который ничего не решал.
*
* Тесты ниже пиннят три вещи, каждая из которых на старом коде красная:
* 1) панель берёт владельца профилей из /api/v1/me и включает CRUD без пропсов;
* 2) в запросы CRUD не уходит X-Admin-Token;
* 3) `profileId` не уходит в /analyze без `profileUserId` (иначе бэкенд молча
* считает по системным весам, рапортуя source="profile").
*/
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WeightProfilePanel } from "../WeightProfilePanel";
import { buildAnalyzeOptions } from "@/hooks/useSiteAnalysis";
import { POI_DEFAULT_WEIGHTS } from "@/lib/api/weightProfiles";
const fetchMock = vi.fn<typeof fetch>();
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const PROFILE = {
id: 1,
user_id: "admin",
profile_name: "Мой профиль",
weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 },
is_default: true,
description: null,
created_at: "2026-05-15T05:30:51Z",
updated_at: "2026-05-15T05:30:51Z",
};
function renderPanel() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<WeightProfilePanel
currentWeights={{ ...POI_DEFAULT_WEIGHTS }}
onWeightsChange={() => {}}
/>
</QueryClientProvider>,
);
}
/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */
async function openPanel() {
const { default: userEvent } = await import("@testing-library/user-event");
await userEvent.setup().click(screen.getByText("POI Веса"));
}
beforeEach(() => {
fetchMock.mockReset();
fetchMock.mockImplementation(async (input) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/api/v1/me")) {
return jsonResponse({
username: "admin",
role: "admin",
allowed_paths: ["/**"],
deny_paths: [],
});
}
if (url.includes("/weight-profiles")) {
return jsonResponse([PROFILE]);
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => {
it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => {
renderPanel();
await openPanel();
// Профили грузятся по user_id из /me, без ручного ввода и без токена.
await waitFor(() =>
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
);
expect(screen.getByText("Профиль:")).toBeTruthy();
});
it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => {
renderPanel();
await openPanel();
await waitFor(() =>
expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(),
);
expect(screen.queryByText(/Admin Token/i)).toBeNull();
expect(screen.queryByText(/Укажите User ID/i)).toBeNull();
});
it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => {
renderPanel();
await openPanel();
await waitFor(() => {
const crudCall = fetchMock.mock.calls.find(([input]) =>
String(input).includes("/weight-profiles"),
);
expect(crudCall).toBeTruthy();
const headers = new Headers(
(crudCall?.[1] as RequestInit | undefined)?.headers,
);
expect(headers.has("X-Admin-Token")).toBe(false);
});
});
});
describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => {
const weights = { ...POI_DEFAULT_WEIGHTS };
it("выбранный профиль уходит вместе с profileUserId", () => {
expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({
profileId: 7,
profileUserId: "admin",
});
});
it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => {
const options = buildAnalyzeOptions(weights, 7, "");
expect(options.profileId).toBeUndefined();
expect(options.weights).toEqual(weights);
});
it("без профиля уходят inline-веса и владелец для default-профиля", () => {
expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({
profileUserId: "admin",
weights,
});
});
});

View file

@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder";
// /fetch-status every 2s, re-triggers analyze когда status=ready.
export type FetchStatus =
| "ready"
| "fetching"
| "failed"
| "not_in_nspd"
| "invalid_format";
"ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format";
export interface FetchStatusResponse {
status: FetchStatus;
@ -52,6 +48,34 @@ export interface AnalyzeOptions {
weights?: Record<string, number> | null;
}
/**
* Собрать options для POST /analyze из состояния панели весов POI.
*
* 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не
* стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id,
* profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is
* not None` не выполняется, и веса тихо падают на системные. Ответ при этом
* рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде
* #2782: `profile_id=1` без `profile_user_id` `tram_stop 0.5` вместо `0.4` из
* профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам.
*
* Когда пользователь неизвестен (dev без Caddy: /api/v1/me 401) шлём inline
* `weights`. Они всегда равны ползункам панели, так что расхождения нет.
*/
export function buildAnalyzeOptions(
weights: Record<string, number>,
profileId: number | null,
profileUserId: string,
): AnalyzeOptions {
if (profileId != null && profileUserId) {
return { profileId, profileUserId };
}
if (profileUserId) {
return { profileUserId, weights };
}
return { weights };
}
/**
* Custom hook для analyze flow с graceful on-demand fetch fallback.
*

View file

@ -91,35 +91,37 @@ export const POI_WEIGHT_MAX = 3;
const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles";
function profilesHeaders(adminToken: string): HeadersInit {
return { "X-Admin-Token": adminToken };
}
// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний
// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и
// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200
// с корректным токеном, с мусорным и без заголовка вовсе.
//
// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены:
// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре
// (подставленный клиентом X-Authenticated-User туда же не проходит);
// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only".
// ── Hooks ─────────────────────────────────────────────────────────────────────
/** List all weight profiles for a given user_id. */
export function useWeightProfiles(userId: string, adminToken: string) {
export function useWeightProfiles(userId: string) {
return useQuery<WeightProfile[]>({
queryKey: ["weight-profiles", userId],
queryFn: () =>
apiFetch<WeightProfile[]>(
`${BASE_PATH}?user_id=${encodeURIComponent(userId)}`,
{
headers: profilesHeaders(adminToken),
},
),
enabled: !!userId && !!adminToken,
enabled: !!userId,
});
}
/** Create a new weight profile. Invalidates the list query on success. */
export function useCreateProfile(adminToken: string) {
export function useCreateProfile() {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileCreate>({
mutationFn: (payload) =>
apiFetch<WeightProfile>(BASE_PATH, {
method: "POST",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
}),
onSuccess: (_, variables) => {
@ -131,11 +133,7 @@ export function useCreateProfile(adminToken: string) {
}
/** Update an existing weight profile by id. */
export function useUpdateProfile(
userId: string,
profileId: number,
adminToken: string,
) {
export function useUpdateProfile(userId: string, profileId: number) {
const qc = useQueryClient();
return useMutation<WeightProfile, Error, WeightProfileUpdate>({
mutationFn: (payload) =>
@ -143,7 +141,6 @@ export function useUpdateProfile(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "PUT",
headers: profilesHeaders(adminToken),
body: JSON.stringify(payload),
},
),
@ -154,16 +151,13 @@ export function useUpdateProfile(
}
/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */
export function useDeleteProfile(userId: string, adminToken: string) {
export function useDeleteProfile(userId: string) {
const qc = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: (profileId) =>
apiFetch<void>(
`${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`,
{
method: "DELETE",
headers: profilesHeaders(adminToken),
},
{ method: "DELETE" },
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] });