ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
This commit is contained in:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
+66 -32
View File
@@ -1,7 +1,7 @@
/**
* The accept-invite screen is the only place an invited user can turn a link
* into a membership, so its states matter: missing token, signed out, preview
* failure, and the happy path.
* The accept-invite screen is now the *only* door into the product: signup is
* closed, so the link must both preview the invitation to a stranger and let
* them create their account by choosing a password. Every state matters.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => {
session: {
ready: true,
user: null as { id: string; email: string } | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
@@ -65,6 +66,16 @@ vi.mock("@/lib/api", () => ({
import AcceptInvitePage from "@/app/accept-invite/page";
const PREVIEW = {
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "member",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
has_account: false,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.token = "tok-123";
@@ -73,12 +84,56 @@ beforeEach(() => {
});
describe("accept-invite", () => {
it("asks a signed-out visitor to authenticate and keeps the token in the link", () => {
it("previews the invite to a signed-out stranger instead of bouncing to login", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
render(<AcceptInvitePage />);
expect(screen.getByText("دعوت به یک سازمان")).toBeInTheDocument();
const login = screen.getByRole("link", { name: "ورود" });
// Losing the token here would strand the invitee on the dashboard.
// The whole point of the closed-signup flow: a brand-new invitee sees who
// invited them without having an account yet.
expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
});
it("offers a set-password form when no account exists for the invited email", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
render(<AcceptInvitePage />);
expect(await screen.findByLabelText(/رمز عبور برای ساخت حساب/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "ساخت حساب و پیوستن" })).toBeInTheDocument();
});
it("creates the account, signs in, and joins on submit", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
mocks.acceptInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
role: "member",
});
render(<AcceptInvitePage />);
fireEvent.change(await screen.findByLabelText(/رمز عبور برای ساخت حساب/), {
target: { value: "password123" },
});
fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" }));
await waitFor(() =>
expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123"),
);
// The password just chosen becomes the login credential, so the invitee
// lands inside the app rather than back at the login screen.
await waitFor(() =>
expect(mocks.session.login).toHaveBeenCalledWith("newbie@example.com", "password123"),
);
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
});
it("asks an invitee whose email already has an account to log in first", async () => {
mocks.previewInvite.mockResolvedValue({ ...PREVIEW, has_account: true });
render(<AcceptInvitePage />);
// A leaked link must never act as a password reset.
const login = await screen.findByRole("link", { name: "ورود و پذیرش دعوت" });
expect(decodeURIComponent(login.getAttribute("href") ?? "")).toBe(
"/login?next=/accept-invite?token=tok-123",
);
@@ -86,36 +141,23 @@ describe("accept-invite", () => {
it("shows the organization and role to the signed-in invitee", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "manager",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
});
mocks.previewInvite.mockResolvedValue({ ...PREVIEW, role: "manager", has_account: true });
render(<AcceptInvitePage />);
expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "پذیرش دعوت" })).toBeInTheDocument();
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
});
it("surfaces the server's reason when the invite is unusable", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockRejectedValue(
new mocks.FakeApiError(
403,
"permission_denied",
"این دعوت‌نامه برای ایمیل دیگری صادر شده است.",
),
new mocks.FakeApiError(409, "conflict", "این دعوت‌نامه قبلاً پذیرفته شده است."),
);
render(<AcceptInvitePage />);
expect(await screen.findByText("دعوت‌نامه قابل استفاده نیست")).toBeInTheDocument();
expect(screen.getByText(/برای ایمیل دیگری/)).toBeInTheDocument();
expect(screen.getByText(/قبلاً پذیرفته/)).toBeInTheDocument();
});
it("reports a missing token instead of calling the API", () => {
@@ -128,14 +170,7 @@ describe("accept-invite", () => {
it("activates the joined organization and redirects after accepting", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "member",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
});
mocks.previewInvite.mockResolvedValue({ ...PREVIEW, has_account: true });
mocks.acceptInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
@@ -146,7 +181,6 @@ describe("accept-invite", () => {
fireEvent.click(await screen.findByRole("button", { name: "پذیرش دعوت" }));
await waitFor(() => expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123"));
// Landing in the new tenant, not whichever one was active before.
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
});