Files
ERP/frontend/src/store/auth.ts
T
whqreza 1f16e44874 feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly

Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)

Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
2026-09-15 19:23:50 +03:30

91 lines
3.6 KiB
TypeScript

// ============================================================
// Auth store — Casdoor session (v2.6, درون‌صفحه‌ای / password grant)
// Dev mode: no token — identity sent via X-User-Role/X-Project-Id
// headers (backend dev fallback) so the app works before Casdoor
// is fully seeded.
// ============================================================
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface AuthUser {
id: string;
casdoor_id: string;
first_name: string;
last_name: string;
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null;
role_label: string | null;
project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null;
projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N)
display_name: string;
}
interface AuthStore {
token: string | null;
user: AuthUser | null;
isDev: boolean;
setToken: (token: string | null) => void;
setUser: (user: AuthUser | null) => void;
setDevUser: (role: "pm" | "admin" | "procurement_manager", projectId: string | null, projectName: string | null) => void;
// پروژه‌های تخصیص‌یافته را می‌نشاند و پروژه‌ی فعال را اعتبارسنجی می‌کند:
// اگر پروژه‌ی ذخیره‌شده (localStorage) باطل شده باشد، به اولین پروژه برمی‌گردد.
setProjects: (projects: { id: string; name: string }[]) => void;
// جابه‌جایی پروژه‌ی فعال بدون هیچ تغییری در نشست/توکن (بدون احراز هویت مجدد)
setActiveProject: (projectId: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
token: null,
user: null,
isDev: false,
setToken: (token) => set({ token, isDev: false }),
setUser: (user) => set({ user, isDev: false }),
setDevUser: (role, projectId, projectName) =>
set({
token: null,
isDev: true,
user: {
id: "dev-user",
casdoor_id: "dev",
first_name: "کاربر",
last_name: "آزمایشی",
phone: null,
role,
role_label: role === "pm" ? "مدیر پروژه (آزمایشی)" : role === "procurement_manager" ? "مدیر تدارکات (آزمایشی)" : "ادمین (آزمایشی)",
project_id: projectId,
project_name: projectName,
projects: projectId ? [{ id: projectId, name: projectName || "" }] : [],
display_name: projectId ? `کاربر آزمایشی — ${projectName || ""}` : "کاربر آزمایشی",
},
}),
setProjects: (projects) =>
set((s) => {
if (!s.user) return {};
const active = projects.find((p) => p.id === s.user!.project_id) ?? projects[0] ?? null;
return {
user: {
...s.user,
projects,
project_id: active?.id ?? null,
project_name: active?.name ?? null,
},
};
}),
setActiveProject: (projectId) =>
set((s) => {
if (!s.user) return {};
const target = (s.user.projects ?? []).find((p) => p.id === projectId);
if (!target) return {};
return { user: { ...s.user, project_id: target.id, project_name: target.name } };
}),
logout: () => set({ token: null, user: null, isDev: false }),
}),
{ name: "procurement-auth-v1" },
),
);