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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user