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
+164 -2
View File
@@ -12,7 +12,7 @@
*/
import * as React from "react";
import type { AdminUser, PlatformRole } from "@agy/contracts";
import type { AdminUser, PlatformInvite, PlatformRole } from "@agy/contracts";
import { PLATFORM_ROLE_LABELS } from "@agy/contracts";
import {
Badge,
@@ -31,6 +31,7 @@ import {
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { orgApi } from "@/lib/api-org";
const ROLE_TONES: Record<PlatformRole, "info" | "success" | "neutral"> = {
owner: "info",
@@ -67,6 +68,71 @@ export default function AdminUsersPage() {
| null
>(null);
// --- platform invitations (v2 D12) ------------------------------------
// A link is how the owner makes a manager without minting a password they then
// have to deliver themselves. The token exists only in the response to `issue`.
const [invites, setInvites] = React.useState<PlatformInvite[]>([]);
const [inviteOpen, setInviteOpen] = React.useState(false);
const [inviteEmail, setInviteEmail] = React.useState("");
const [inviteNote, setInviteNote] = React.useState("");
const [issuing, setIssuing] = React.useState(false);
const [inviteError, setInviteError] = React.useState<string | null>(null);
const [issuedLink, setIssuedLink] = React.useState<string | null>(null);
const [revoking, setRevoking] = React.useState<string | null>(null);
const loadInvites = React.useCallback(async () => {
try {
setInvites(await orgApi.listPlatformInvites());
} catch {
// The invitation console is diagnostic; a failure must not break the page
// whose real job is the account list.
setInvites([]);
}
}, []);
React.useEffect(() => {
void loadInvites();
}, [loadInvites]);
const onIssueInvite = async (event: React.FormEvent) => {
event.preventDefault();
setIssuing(true);
setInviteError(null);
try {
const issued = await orgApi.issuePlatformInvite({
email: inviteEmail.trim() || undefined,
note: inviteNote.trim() || undefined,
});
// Shown once, never retrievable — the same discipline as a temp password.
setIssuedLink(issued.invite_url);
toast("لینک دعوت ساخته شد", "success", "این لینک فقط یک بار نمایش داده می‌شود.");
setInviteEmail("");
setInviteNote("");
await loadInvites();
} catch (err) {
setInviteError(err instanceof ApiError ? err.message : "ساخت لینک دعوت ناموفق بود.");
} finally {
setIssuing(false);
}
};
const onRevokeInvite = async (inviteId: string) => {
setRevoking(inviteId);
try {
await orgApi.revokePlatformInvite(inviteId);
toast("لینک دعوت لغو شد", "success");
await loadInvites();
} catch (err) {
toast(
"لغو دعوت ناموفق بود",
"danger",
err instanceof ApiError ? err.message : undefined,
);
} finally {
setRevoking(null);
}
};
const toast = React.useCallback((title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
@@ -191,7 +257,12 @@ export default function AdminUsersPage() {
خودشان را می‌سازند؛ کاربران عادی با دعوت به سازمان‌ها می‌پیوندند.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setInviteOpen(true)}>
دعوت مدیر جدید
</Button>
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
</div>
</header>
<div className="grid gap-3 sm:grid-cols-3">
@@ -200,6 +271,48 @@ export default function AdminUsersPage() {
<Stat label="کاربران عادی" value={plainUsers.length} />
</div>
<Card>
<CardTitle>لینک‌های دعوت فعال</CardTitle>
<div className="mt-3 flex flex-col gap-2">
<p className="text-sm text-slate-400">
هر لینک یک بار قابل استفاده است و پس از انقضا یا لغو شدن از کار می‌افتد.
</p>
{invites.length === 0 ? (
<p className="text-xs text-slate-500">لینک فعالی وجود ندارد.</p>
) : (
<ul className="flex flex-col gap-2">
{invites.map((invite) => (
<li
key={invite.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="ltr-inline truncate text-sm text-slate-200">
{invite.email ?? "بدون ایمیل مشخص"}
</span>
<span className="text-xs text-slate-500">
{PLATFORM_ROLE_LABELS[invite.platform_role as PlatformRole] ??
invite.platform_role}
{" · انقضا: "}
{new Date(invite.expires_at).toLocaleDateString("fa-IR")}
{invite.note ? ` · ${invite.note}` : ""}
</span>
</div>
<Button
size="sm"
variant="danger"
loading={revoking === invite.id}
onClick={() => void onRevokeInvite(invite.id)}
>
لغو
</Button>
</li>
))}
</ul>
)}
</div>
</Card>
{users.length === 0 ? (
<EmptyState title="کاربری وجود ندارد" description="هنوز هیچ حسابی ساخته نشده است." />
) : (
@@ -389,6 +502,55 @@ export default function AdminUsersPage() {
</p>
</Modal>
<Modal
open={inviteOpen}
title="دعوت مدیر جدید"
onClose={() => {
setInviteOpen(false);
setInviteError(null);
}}
>
<form onSubmit={onIssueInvite} className="flex flex-col gap-4">
<Field
label="ایمیل موردنظر (اختیاری)"
type="email"
dir="ltr"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
hint="خالی بگذارید تا لینک برای هر کسی که در اختیار دارد کار کند."
/>
<Field
label="یادداشت (اختیاری)"
value={inviteNote}
onChange={(event) => setInviteNote(event.target.value)}
placeholder="مثلاً: همکار تیم فروش"
/>
{inviteError && <p className="text-xs text-rose-300">{inviteError}</p>}
<Button type="submit" loading={issuing}>
ساخت لینک دعوت
</Button>
</form>
</Modal>
<Modal open={issuedLink !== null} title="لینک دعوت" onClose={() => setIssuedLink(null)}>
<div className="flex flex-col gap-4">
<p className="text-sm text-slate-300">
این لینک را برای مخاطب بفرستید. فقط همین یک بار نمایش داده می‌شود.
</p>
<div className="ltr-inline break-all rounded-lg border border-surface-border bg-surface-base p-3 text-xs text-slate-200">
{issuedLink}
</div>
<Button
onClick={() => {
void navigator.clipboard.writeText(issuedLink ?? "");
toast("کپی شد", "success");
}}
>
کپی لینک
</Button>
</div>
</Modal>
<ConfirmDialog
open={confirm?.kind === "demote"}
title="تنزل مدیر به کاربر عادی؟"
+56 -10
View File
@@ -57,6 +57,8 @@ function AcceptInviteCard() {
const [loading, setLoading] = React.useState(true);
const [accepting, setAccepting] = React.useState(false);
const [password, setPassword] = React.useState("");
// Only used by a platform invite that pinned no email.
const [email, setEmail] = React.useState("");
// The invitee may not have an account yet; after they sign in we send them
// straight back here so the token is not lost.
@@ -94,9 +96,16 @@ function AcceptInviteCard() {
}, [ready, token, user]);
const finish = React.useCallback(
async (organizationId: string) => {
async (organizationId: string, kind: string = "organization") => {
// Land the invitee inside the organization they just joined rather than
// whichever tenant happened to be active before.
// whichever tenant happened to be active before. A *platform* invite
// creates no organization: the new manager is sent to the creation wizard,
// which is the only useful next step for them.
if (kind === "platform" || !organizationId) {
await refresh();
router.replace("/onboarding");
return;
}
setActiveOrganization(organizationId);
await refresh();
router.replace("/dashboard");
@@ -110,7 +119,7 @@ function AcceptInviteCard() {
setAccepting(true);
try {
const result = await api.acceptInvite(token);
await finish(result.organization_id);
await finish(result.organization_id, result.kind);
} catch (err) {
setError(err instanceof ApiError ? err.message : "پذیرش دعوت‌نامه ناموفق بود.");
} finally {
@@ -125,11 +134,12 @@ function AcceptInviteCard() {
setError(null);
setAccepting(true);
try {
const result = await api.acceptInvite(token, password);
const result = await api.acceptInvite(token, password, email.trim() || undefined);
// Sign in with the password just chosen so the invitee lands inside the
// app instead of being bounced back to the login screen.
await login(preview.email, password);
await finish(result.organization_id);
// app instead of being bounced back to the login screen. A platform invite
// that pinned no email gets the address the holder typed here.
await login(email.trim() || preview.email, password);
await finish(result.organization_id, result.kind);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت حساب ناموفق بود.");
} finally {
@@ -193,16 +203,40 @@ function AcceptInviteCard() {
if (!preview) return null;
const roleLabel = ROLE_LABELS[preview.role] ?? preview.role;
const isPlatform = preview.kind === "platform";
const signedInWithInvitedEmail =
user !== null && user.email.trim().toLowerCase() === preview.email.trim().toLowerCase();
// A platform invite that pinned no email is a bearer link: the holder supplies
// the address they want to sign in with, and the form asks for it.
const needsEmail = isPlatform && !preview.email && user === null;
return (
<Centered>
<Card className="anim-rise">
<CardTitle>دعوت به {preview.organization_name}</CardTitle>
<CardTitle>
{isPlatform ? "ساخت حساب مدیر" : `دعوت به ${preview.organization_name}`}
</CardTitle>
<CardHint>
برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
شده‌اید.
{isPlatform ? (
<>
به سامانه دعوت شده‌اید
{preview.email ? (
<>
{" "}
با ایمیل <span className="ltr-inline">{preview.email}</span>
</>
) : (
""
)}{" "}
و پس از ساخت حساب <b>{roleLabel}</b> خواهید بود؛ سپس می‌توانید برای خودتان
یک سازمان و اپلیکیشن بسازید.
</>
) : (
<>
برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
شده‌اید.
</>
)}
</CardHint>
<div className="mt-4 flex items-center gap-2">
@@ -220,6 +254,18 @@ function AcceptInviteCard() {
// Brand-new invitee: the link creates the account. The password they
// choose here becomes their login credential.
<form onSubmit={onCreateAndAccept} className="mt-5 flex flex-col gap-4">
{needsEmail && (
<Field
label="ایمیل حساب شما"
type="email"
dir="ltr"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
hint="این ایمیل نام کاربری ورود شما خواهد بود."
/>
)}
<Field
label="رمز عبور برای ساخت حساب"
type="password"
+8
View File
@@ -25,6 +25,14 @@ export default function OnboardingPage() {
if (ready && !user) router.replace("/login");
}, [ready, user, router]);
// Somebody who already has an organization has nothing to do here — the wizard
// is for managers the owner invited who have built nothing yet. Without this,
// the page is a dead end that happily creates a *second* organization.
React.useEffect(() => {
if (!ready || !user) return;
if (organizations.length > 0) router.replace("/dashboard");
}, [ready, user, organizations, router]);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
+73 -2
View File
@@ -3,8 +3,79 @@
*
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
*/
import type {
IssuedPlatformInvite,
PlatformInvite,
} from "@agy/contracts";
import { apiRequest } from "./api";
/** The platform owner's account invitations (split into organizations of the future). */
export const orgApi = {
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
/** Create an invitation link for a new manager account. */
issuePlatformInvite: (body: { email?: string; note?: string } = {}) =>
apiRequest<IssuedPlatformInvite>("/api/admin/platform-invites", {
method: "POST",
body,
}),
/** The invitations that still hold a valid, unredeemed link. */
listPlatformInvites: () =>
apiRequest<PlatformInvite[]>("/api/admin/platform-invites"),
revokePlatformInvite: (inviteId: string) =>
apiRequest<{ ok: boolean }>(
`/api/admin/platform-invites/${encodeURIComponent(inviteId)}/revoke`,
{ method: "POST" },
),
/** Share an organization: the members' view of the same data. */
listOrgInvites: (orgId: string) =>
apiRequest<
Array<{
id: string;
email: string;
role: string;
expires_at: string;
accepted_at: string | null;
}>
>(`/api/organizations/${orgId}/invites`),
transferOrgOwnership: (
orgId: string,
body: { new_owner_user_id: string; confirm: string },
) =>
apiRequest<{
id: string;
name: string;
slug: string;
owner_user_id: string;
role: string;
}>(`/api/organizations/${orgId}/transfer-ownership`, {
method: "POST",
body,
}),
leaveOrganization: (orgId: string) =>
apiRequest<void>(`/api/organizations/${orgId}/leave`, { method: "POST" }),
revokeOrgInvite: (orgId: string, inviteId: string) =>
apiRequest<void>(
`/api/organizations/${orgId}/invites/${encodeURIComponent(inviteId)}`,
{ method: "DELETE" },
),
};
/**
* True when the signed-in account still needs the creation wizard: a manager
* with no organization yet (v2 D12). The owner is excluded by the server.
*
* A hook rather than a read of `organizations` in each page because "manager
* with zero orgs" is a *platform* fact, and computing it locally would duplicate
* the server's rule — which then drifts the first time the rule changes.
*/
export async function fetchNeedsOnboarding(): Promise<boolean> {
return (await apiRequest<{ needs_onboarding: boolean }>("/api/auth/me")).needs_onboarding;
}
+2 -2
View File
@@ -181,10 +181,10 @@ export const api = {
apiRequest<import("@agy/contracts").InvitePreview>(
`/api/invites/${encodeURIComponent(token)}`,
),
acceptInvite: (token: string, password?: string) =>
acceptInvite: (token: string, password?: string, email?: string) =>
apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", {
method: "POST",
body: password ? { token, password } : { token },
body: { token, password, email },
}),
updateMember: (
orgId: string,
+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();
});
});