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.
This commit is contained in:
@@ -16,8 +16,9 @@ export interface AuthUser {
|
||||
phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null;
|
||||
role_label: string | null;
|
||||
project_id: string | null;
|
||||
project_id: string | null; // پروژهی فعال (PM) — با سوییچر عوض میشود
|
||||
project_name: string | null;
|
||||
projects?: { id: string; name: string }[]; // همهی پروژههای تخصیصیافته (1:N)
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,11 @@ interface AuthStore {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -53,9 +59,30 @@ export const useAuthStore = create<AuthStore>()(
|
||||
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" },
|
||||
|
||||
Reference in New Issue
Block a user