feat(org): platform invites, roles, org sharing (v2 phase A1-1)

- New platform_invites table (migration a1b2c3d4e5f1) + PlatformInviteRow
- Owner-only endpoints in admin_users.py: issue/list/revoke platform invites
- invites.py: two-kind resolution (org + platform), email-supplied accept path
- auth.py /me gains needs_onboarding (manager, 0 orgs, not owner)
- organizations.py: transfer-ownership, leave (last owner guard), list/revoke org invites
- contracts + web UI: admin/users invite modal, accept-invite platform variant, onboarding gating
- Tests: test_org_platform_invites.py (18), org-sharing.test.tsx; report under docs/plan/reports

Note: (app)/settings "اشتراک‌گذاری سازمان" section (1.10) remains presentational work;
endpoints are done and tested.
This commit is contained in:
mjalmoudy94
2026-10-14 09:14:09 +03:30
parent 3c8296e354
commit 5095a29df9
20 changed files with 1933 additions and 37 deletions
+3 -1
View File
@@ -117,7 +117,9 @@ describe("accept-invite", () => {
fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" }));
await waitFor(() =>
expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123"),
// The third argument is the email a *platform* invite that pinned none
// needs; an organization invite never uses it, so it is undefined here.
expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123", undefined),
);
// The password just chosen becomes the login credential, so the invitee
// lands inside the app rather than back at the login screen.
+152
View File
@@ -0,0 +1,152 @@
/**
* §v2-A1.1 — the platform-invite side of the UI.
*
* The screen is the only door into the product, so the two things that matter
* are that the *right* door opens and that a bearer link without a pinned email
* still collects the address the holder wants. A blank invite that silently
* created an account with an empty email would look like it worked and then be
* unloginnable.
*/
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
session: {
ready: true,
user: null as { id: string; email: string } | null,
organizations: [] as Array<{ id: string; name: string; slug: string }>,
activeOrganization: null as { id: string; name: string; slug: string } | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
previewInvite: vi.fn(),
acceptInvite: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("token=tok-123"),
useRouter: () => ({ replace }),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
ApiError: FakeApiError,
api: { previewInvite: mocks.previewInvite, acceptInvite: mocks.acceptInvite },
};
});
const replace = vi.fn();
Object.assign(mocks, { replace });
import AcceptInvitePage from "@/app/accept-invite/page";
const PLATFORM_PREVIEW = {
kind: "platform",
organization_id: "",
organization_name: "",
email: "",
role: "manager",
expires_at: "2026-10-16T00:00:00.000Z",
already_member: false,
has_account: false,
platform_role: "manager",
};
const ORG_PREVIEW = {
kind: "organization",
organization_id: "org-1",
organization_name: "شرکت نمونه",
email: "invitee@example.com",
role: "admin",
expires_at: "2026-10-16T00:00:00.000Z",
already_member: false,
has_account: false,
platform_role: "manager",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.session.user = null;
mocks.session.organizations = [];
mocks.acceptInvite.mockResolvedValue({
kind: "platform",
organization_id: "",
organization_name: "",
role: "manager",
});
});
describe("the accept-invite screen", () => {
it("titles a platform invitation as an account, not a joining", async () => {
mocks.previewInvite.mockResolvedValue(PLATFORM_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
// An organization invitation says where you are going; a platform one says who you become.
expect(screen.queryByText(/^دعوت به /)).not.toBeInTheDocument();
});
it("asks for the email when the invitation pinned none", async () => {
mocks.previewInvite.mockResolvedValue(PLATFORM_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
// A bearer link with no address would otherwise create an unloginnable account.
expect(screen.getByText("ایمیل حساب شما")).toBeInTheDocument();
});
it("does not ask for an email when the owner pinned one", async () => {
mocks.previewInvite.mockResolvedValue({
...PLATFORM_PREVIEW,
email: "pinned@example.com",
});
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
expect(screen.queryByText("ایمیل حساب شما")).not.toBeInTheDocument();
expect(screen.getByText("pinned@example.com")).toBeInTheDocument();
});
it("still titles an organization invitation as a joining", async () => {
mocks.previewInvite.mockResolvedValue(ORG_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() =>
expect(screen.getByText("دعوت به شرکت نمونه")).toBeInTheDocument(),
);
expect(screen.queryByText("ساخت حساب مدیر")).not.toBeInTheDocument();
});
it("sends the typed email with the redemption", async () => {
mocks.previewInvite.mockResolvedValue(PLATFORM_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
const email = document.querySelector('input[type="email"]') as HTMLInputElement;
const password = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
expect(email).not.toBeNull();
expect(password).not.toBeNull();
});
});