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.
91 lines
3.6 KiB
TypeScript
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" },
|
|
),
|
|
);
|