feat(integration): wire v2 workstreams together (engine policy/budget, sandbox, tenant checks, order route, docs)

Engine honours org policy and monthly budget, legacy runner skips engine tasks,
sandbox session provisioned on demand, project fallback for threads, reviewer
fallback, tenant checks on project ids, memory reflection respects grants,
expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 20:57:56 +03:30
co-authored by Claude Sonnet 5.5
parent 36564e3f2b
commit fef5a9deab
43 changed files with 3024 additions and 221 deletions
+6 -4
View File
@@ -95,12 +95,14 @@ function payload<T>(event: { data: Record<string, unknown> }): T {
}
export default function ApplicationPage() {
const { activeOrganization, user } = useSession();
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const orgSlug = activeOrganization?.slug ?? "";
// Org membership is enough to *see* everything here; the server is the real
// boundary for the actions, which is why a 403 still surfaces as a message.
const canManage = Boolean(user);
// Org membership is enough to *see* everything here. The actions (re-run,
// rollback, production) need the org's admin or owner role, the same rule the
// server applies; the server stays the real boundary, which is why a 403 still
// surfaces as a message.
const canManage = activeOrganization?.role === "owner" || activeOrganization?.role === "admin";
const [tab, setTab] = React.useState<Tab>("pipeline");
const [project, setProject] = React.useState<Project | null>(null);
+1 -1
View File
@@ -136,7 +136,7 @@ export default function MemoryPage() {
// Live: another member (or a reflection) changed memory while this page was open.
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
if ((event.type as string) === MEMORY_CHANGED_EVENT) void load();
if (event.type === MEMORY_CHANGED_EVENT) void load();
});
const visible = React.useMemo(
@@ -0,0 +1,339 @@
"use client";
/**
* Sharing controls on the settings "members" tab (v2 TODO 1.10): the invitations
* nobody has accepted yet, handing the organization to an active admin, and
* leaving it.
*
* The server is the boundary for every rule: who may list or revoke invitations,
* that only the owner transfers, that the target must be an active admin, that the
* typed slug must match, and that the last owner cannot leave. The UI only hides
* the controls a role can never use, and shows the server's message when a request
* is still refused.
*/
import * as React from "react";
import { useRouter } from "next/navigation";
import type { Member, OrganizationWithRole } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { orgApi } from "@/lib/api-org";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<string, string> = {
owner: "مالک",
admin: "مدیر",
manager: "سرپرست",
member: "عضو",
viewer: "بیننده",
};
interface PendingInvite {
id: string;
email: string;
role: string;
expires_at: string;
accepted_at: string | null;
}
function errorText(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export interface SharingSectionProps {
org: OrganizationWithRole;
members: Member[];
/** Pending invitations are shown to members who can manage members. */
canManageMembers: boolean;
/** Called after a change that makes the parent's member list stale. */
onChanged: () => void;
}
export function SharingSection({
org,
members,
canManageMembers,
onChanged,
}: SharingSectionProps) {
const router = useRouter();
const { organizations, refresh } = useSession();
const isOwner = org.role === "owner";
// --- pending invitations ---------------------------------------------------
const [invites, setInvites] = React.useState<PendingInvite[]>([]);
const [invitesLoading, setInvitesLoading] = React.useState(false);
const [invitesError, setInvitesError] = React.useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = React.useState<PendingInvite | null>(null);
const [revoking, setRevoking] = React.useState(false);
// --- transfer ownership (owner only) ---------------------------------------
const [targetUserId, setTargetUserId] = React.useState("");
const [transferOpen, setTransferOpen] = React.useState(false);
const [slugInput, setSlugInput] = React.useState("");
const [transferring, setTransferring] = React.useState(false);
const [transferError, setTransferError] = React.useState<string | null>(null);
// --- leave -------------------------------------------------------------------
const [leaveOpen, setLeaveOpen] = React.useState(false);
const [leaving, setLeaving] = React.useState(false);
const [leaveError, setLeaveError] = React.useState<string | null>(null);
const admins = members.filter((m) => m.role === "admin" && m.status === "active");
const target = admins.find((m) => m.user_id === targetUserId) ?? null;
// Same comparison the server makes (trimmed, case-insensitive), so the button
// enables exactly when the request would be accepted.
const slugMatches = slugInput.trim().toLowerCase() === org.slug.trim().toLowerCase();
React.useEffect(() => {
if (!canManageMembers) return;
let cancelled = false;
setInvitesLoading(true);
setInvitesError(null);
orgApi
.listOrgInvites(org.id)
.then((rows) => {
if (!cancelled) setInvites(rows.filter((row) => row.accepted_at == null));
})
.catch((err: unknown) => {
if (!cancelled) setInvitesError(errorText(err, "دریافت دعوت‌های در انتظار ناموفق بود."));
})
.finally(() => {
if (!cancelled) setInvitesLoading(false);
});
return () => {
cancelled = true;
};
}, [canManageMembers, org.id]);
const onRevoke = async () => {
const invite = pendingRevoke;
if (!invite) return;
setRevoking(true);
setInvitesError(null);
try {
await orgApi.revokeOrgInvite(org.id, invite.id);
setInvites((current) => current.filter((row) => row.id !== invite.id));
} catch (err) {
setInvitesError(errorText(err, "لغو دعوت ناموفق بود."));
} finally {
setPendingRevoke(null);
setRevoking(false);
}
};
const onTransfer = async () => {
if (!target || !slugMatches) return;
setTransferring(true);
setTransferError(null);
try {
await orgApi.transferOrgOwnership(org.id, {
new_owner_user_id: target.user_id,
confirm: slugInput.trim(),
});
setTransferOpen(false);
setSlugInput("");
setTargetUserId("");
// Our own role just changed (owner to admin), and the session carries it.
await refresh();
onChanged();
} catch (err) {
setTransferError(errorText(err, "انتقال مالکیت ناموفق بود."));
} finally {
setTransferring(false);
}
};
const onLeave = async () => {
setLeaving(true);
setLeaveError(null);
try {
await orgApi.leaveOrganization(org.id);
setLeaveOpen(false);
const remaining = organizations.filter((o) => o.id !== org.id);
await refresh();
// Nothing left to work in: the creation wizard is the only useful next screen.
if (remaining.length === 0) router.replace("/onboarding");
} catch (err) {
setLeaveOpen(false);
setLeaveError(errorText(err, "ترک سازمان ناموفق بود."));
} finally {
setLeaving(false);
}
};
return (
<>
{canManageMembers && (
<Card>
<CardTitle>دعوت‌های در انتظار پذیرش</CardTitle>
<CardHint>
لینک‌هایی که هنوز استفاده نشده‌اند. اگر لینکی به نشانی اشتباهی رفته، آن را لغو کنید.
</CardHint>
<div className="mt-4">
{invitesError ? (
<ErrorState title="دعوت‌ها در دسترس نیست" message={invitesError} />
) : invitesLoading ? (
<LoadingState label="در حال دریافت دعوت‌ها…" />
) : invites.length === 0 ? (
<EmptyState title="دعوت در انتظاری وجود ندارد" />
) : (
<ul className="flex flex-col divide-y divide-surface-border">
{invites.map((invite) => (
<li
key={invite.id}
className="flex flex-wrap items-center justify-between gap-3 py-3"
>
<div className="flex flex-col">
<span className="ltr-inline text-sm text-slate-200">{invite.email}</span>
<span className="text-xs text-slate-500">
معتبر تا {new Date(invite.expires_at).toLocaleDateString("fa-IR")}
</span>
</div>
<div className="flex items-center gap-2">
<Badge tone="neutral">{ROLE_LABELS[invite.role] ?? invite.role}</Badge>
<Button size="sm" variant="ghost" onClick={() => setPendingRevoke(invite)}>
لغو دعوت
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</Card>
)}
{isOwner && (
<Card>
<CardTitle>انتقال مالکیت</CardTitle>
<CardHint>
مالکیت را به یکی از مدیران فعال سازمان بسپارید. پس از انتقال، شما به مدیر سازمان
تبدیل می‌شوید.
</CardHint>
{admins.length === 0 ? (
<p className="mt-4 text-xs text-slate-500">
هنوز مدیر فعالی در سازمان نیست. ابتدا یکی از اعضا را به نقش «مدیر» ببرید.
</p>
) : (
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Select
label="مدیر جدید"
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
options={[
{ value: "", label: "یک مدیر را انتخاب کنید" },
...admins.map((m) => ({ value: m.user_id, label: m.name || m.email })),
]}
/>
</div>
<Button
variant="secondary"
disabled={!target}
onClick={() => {
setSlugInput("");
setTransferError(null);
setTransferOpen(true);
}}
>
ادامه و تأیید
</Button>
</div>
)}
</Card>
)}
<Card>
<CardTitle>ترک سازمان</CardTitle>
<CardHint>
پس از ترک سازمان، دسترسی شما به آن قطع می‌شود. آخرین مالک سازمان نمی‌تواند آن را ترک
کند؛ پیش از آن باید مالکیت را منتقل کنید.
</CardHint>
{leaveError && (
<div className="mt-3">
<ErrorState title="ترک سازمان انجام نشد" message={leaveError} />
</div>
)}
<div className="mt-4">
<Button variant="danger" onClick={() => setLeaveOpen(true)}>
ترک سازمان
</Button>
</div>
</Card>
<ConfirmDialog
open={pendingRevoke !== null}
title="لغو دعوت"
description={
pendingRevoke
? `دعوت «${pendingRevoke.email}» باطل می‌شود و لینک آن دیگر کار نمی‌کند.`
: undefined
}
confirmLabel={revoking ? "در حال لغو…" : "لغو دعوت"}
onConfirm={() => void onRevoke()}
onCancel={() => setPendingRevoke(null)}
/>
<Modal
open={transferOpen}
title="تأیید انتقال مالکیت"
description={
target
? `مالکیت «${org.name}» به «${target.name || target.email}» منتقل می‌شود. برای ادامه، نامک سازمان را تایپ کنید.`
: undefined
}
onClose={() => setTransferOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setTransferOpen(false)}>
انصراف
</Button>
<Button
variant="danger"
loading={transferring}
disabled={!slugMatches}
onClick={() => void onTransfer()}
>
انتقال مالکیت
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<Field
label="نامک سازمان"
dir="ltr"
autoComplete="off"
placeholder={org.slug}
hint={`دقیقاً همین نامک را وارد کنید: ${org.slug}`}
value={slugInput}
onChange={(e) => setSlugInput(e.target.value)}
/>
{transferError && <ErrorState title="انتقال انجام نشد" message={transferError} />}
</div>
</Modal>
<ConfirmDialog
open={leaveOpen}
title="ترک سازمان"
description={`دسترسی شما به «${org.name}» قطع می‌شود.`}
confirmLabel={leaving ? "در حال ترک…" : "ترک سازمان"}
onConfirm={() => void onLeave()}
onCancel={() => setLeaveOpen(false)}
/>
</>
);
}
+8
View File
@@ -36,6 +36,7 @@ import {
type McpTokenRow,
} from "@/lib/api-org";
import { useSession } from "@/lib/session";
import { SharingSection } from "./_components/sharing-section";
const ROLE_LABELS: Record<Role, string> = {
owner: "مالک",
@@ -476,6 +477,13 @@ export default function SettingsPage() {
)}
</Card>
)}
<SharingSection
org={activeOrganization}
members={members}
canManageMembers={canManageMembers}
onChanged={load}
/>
</>
) : tab === "app" ? (
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
+28 -2
View File
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
import { ApiError } from "@/lib/api";
import { fetchNeedsOnboarding } from "@/lib/api-org";
import { useSession } from "@/lib/session";
/**
@@ -17,6 +18,24 @@ function nextDestination(fallback: string): string {
return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback;
}
/**
* Where a signed-in account lands. An explicit `next` wins, so an invite followed
* before signing in (`/accept-invite?token=...`) is not replaced by the wizard, even
* for an account with no organization yet. Without one, a manager with no
* organization still has to build one in the wizard (v2 D12), so the server's
* `needs_onboarding` decides. If that lookup fails, keep the old landing rather than
* strand the user on the login screen.
*/
async function landingAfterSignIn(): Promise<string> {
const next = nextDestination("");
if (next) return next;
try {
return (await fetchNeedsOnboarding()) ? "/onboarding" : "/dashboard";
} catch {
return "/dashboard";
}
}
export default function LoginPage() {
const router = useRouter();
const { login, user, ready } = useSession();
@@ -26,7 +45,14 @@ export default function LoginPage() {
const [submitting, setSubmitting] = React.useState(false);
React.useEffect(() => {
if (ready && user) router.replace(nextDestination("/dashboard"));
if (!ready || !user) return;
let cancelled = false;
void landingAfterSignIn().then((destination) => {
if (!cancelled) router.replace(destination);
});
return () => {
cancelled = true;
};
}, [ready, user, router]);
const onSubmit = async (event: React.FormEvent) => {
@@ -35,7 +61,7 @@ export default function LoginPage() {
setSubmitting(true);
try {
await login(email, password);
router.replace(nextDestination("/dashboard"));
router.replace(await landingAfterSignIn());
} catch (err) {
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود.");
} finally {
+13 -8
View File
@@ -19,8 +19,8 @@ const mocks = vi.hoisted(() => ({
session: {
ready: true,
user: null as { id: string; email: string } | null,
organizations: [] as Array<{ id: string; slug: string }>,
activeOrganization: null as { id: string; slug: string } | null,
organizations: [] as Array<{ id: string; slug: string; role: string }>,
activeOrganization: null as { id: string; slug: string; role: string } | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
@@ -79,7 +79,7 @@ vi.mock("@/lib/api-delivery", () => ({
import ApplicationPage from "@/app/(app)/app/page";
import { stripAnsi } from "@/app/(app)/app/_components/pipeline-console";
const ORG = { id: "org-1", slug: "acme" };
const ORG = { id: "org-1", slug: "acme", role: "admin" };
const PROJECT = {
id: "proj-1",
@@ -143,10 +143,11 @@ const PRODUCTION = {
deploys: [],
};
function bootstrap({ asOwner = true } = {}) {
function bootstrap({ asOwner = true, role = ORG.role } = {}) {
const org = { ...ORG, role };
mocks.session.user = asOwner ? { id: "u-1", email: "owner@example.com" } : null;
mocks.session.organizations = [ORG];
mocks.session.activeOrganization = ORG;
mocks.session.organizations = [org];
mocks.session.activeOrganization = org;
mocks.listProjects.mockResolvedValue([PROJECT]);
mocks.getRuntime.mockResolvedValue(RUNTIME);
mocks.listRuns.mockResolvedValue([run()]);
@@ -279,12 +280,16 @@ describe("the Application page", () => {
}
});
it("hides the deploy button from a user without a session owner", async () => {
bootstrap({ asOwner: false });
it("hides the deploy actions from an org member who is not an admin", async () => {
bootstrap({ role: "member" });
render(<ApplicationPage />);
await waitFor(() => expect(screen.getByText("اپلیکیشن نمونه")).toBeInTheDocument());
expect(screen.queryByText("اجرای دوباره")).not.toBeInTheDocument();
screen.getByRole("tab", { name: "production" }).click();
await waitFor(() => expect(screen.getByText("مقصد production")).toBeInTheDocument());
expect(screen.queryByText("استقرار در production")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,113 @@
/**
* Post-login landing (v2 D12, TODO 1.9).
*
* A manager who has no organization yet must land in the creation wizard, not on
* an empty dashboard. Everyone else keeps the old destination, and a failed
* `needs_onboarding` lookup must not strand the user on the login screen.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
router: { replace: vi.fn() },
session: {
ready: true,
user: null as { id: string; email: string } | null,
login: vi.fn(async () => {}),
},
fetchNeedsOnboarding: vi.fn(),
}));
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
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 };
});
vi.mock("@/lib/api-org", () => ({
fetchNeedsOnboarding: mocks.fetchNeedsOnboarding,
}));
import LoginPage from "@/app/login/page";
function signIn() {
fireEvent.change(screen.getByLabelText("ایمیل"), {
target: { value: "manager@example.com" },
});
fireEvent.change(screen.getByLabelText("رمز عبور"), {
target: { value: "secret-pass" },
});
fireEvent.click(screen.getByRole("button", { name: "ورود" }));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.session.ready = true;
mocks.session.user = null;
window.history.replaceState({}, "", "/");
});
describe("the post-login landing", () => {
it("sends a manager with no organization to the wizard after signing in", async () => {
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(true);
render(<LoginPage />);
signIn();
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/onboarding"));
expect(mocks.session.login).toHaveBeenCalledWith("manager@example.com", "secret-pass");
});
it("keeps the dashboard for an account that already has an organization", async () => {
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(false);
render(<LoginPage />);
signIn();
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/dashboard"));
expect(mocks.router.replace).not.toHaveBeenCalledWith("/onboarding");
});
it("applies the same rule to an account that is already signed in", async () => {
mocks.session.user = { id: "u-1", email: "manager@example.com" };
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(true);
render(<LoginPage />);
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/onboarding"));
});
it("falls back to the dashboard when the onboarding lookup fails", async () => {
mocks.fetchNeedsOnboarding.mockRejectedValueOnce(new Error("network down"));
render(<LoginPage />);
signIn();
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/dashboard"));
expect(mocks.router.replace).not.toHaveBeenCalledWith("/onboarding");
});
it("honours an explicit next, such as a pending invite, over onboarding", async () => {
window.history.replaceState({}, "", "/login?next=/accept-invite?token=x");
mocks.fetchNeedsOnboarding.mockResolvedValueOnce(true);
render(<LoginPage />);
signIn();
await waitFor(() =>
expect(mocks.router.replace).toHaveBeenCalledWith("/accept-invite?token=x"),
);
expect(mocks.router.replace).not.toHaveBeenCalledWith("/onboarding");
});
});
@@ -0,0 +1,243 @@
/**
* Settings → members: the sharing section (v2 TODO 1.10).
*
* The rules that matter in the UI are the ones a role must never see or use: only
* the owner gets the transfer section, and the transfer cannot be sent before the
* organization's slug has been typed (the server checks it again). A server refusal
* is shown as its own message, not as a generic failure.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Member, OrganizationWithRole } from "@agy/contracts";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
router: { replace: vi.fn() },
session: {
organizations: [] as OrganizationWithRole[],
refresh: vi.fn(async () => {}),
},
orgApi: {
listOrgInvites: vi.fn(),
revokeOrgInvite: vi.fn(),
transferOrgOwnership: vi.fn(),
leaveOrganization: vi.fn(),
},
};
});
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError }));
vi.mock("@/lib/api-org", () => ({ orgApi: mocks.orgApi }));
import { SharingSection } from "@/app/(app)/settings/_components/sharing-section";
const ORG: OrganizationWithRole = {
id: "org-1",
name: "شرکت نمونه",
slug: "acme",
owner_user_id: "u-owner",
role: "owner",
};
const MEMBERS: Member[] = [
{
id: "m-1",
user_id: "u-owner",
email: "owner@acme.test",
name: "مالک",
role: "owner",
status: "active",
created_at: "2026-10-01T00:00:00Z",
},
{
id: "m-2",
user_id: "u-ali",
email: "ali@acme.test",
name: "علی",
role: "admin",
status: "active",
created_at: "2026-10-02T00:00:00Z",
},
{
id: "m-3",
user_id: "u-sara",
email: "sara@acme.test",
name: "سارا",
role: "member",
status: "active",
created_at: "2026-10-03T00:00:00Z",
},
];
const INVITES = [
{
id: "inv-1",
email: "new.hire@acme.test",
role: "member",
expires_at: "2026-10-30T00:00:00Z",
accepted_at: null,
},
{
id: "inv-2",
email: "joined@acme.test",
role: "member",
expires_at: "2026-10-30T00:00:00Z",
accepted_at: "2026-10-05T00:00:00Z",
},
];
function renderSection(
overrides: Partial<OrganizationWithRole> = {},
canManageMembers = true,
) {
const onChanged = vi.fn();
const org: OrganizationWithRole = { ...ORG, ...overrides };
mocks.session.organizations = [org];
render(
<SharingSection
org={org}
members={MEMBERS}
canManageMembers={canManageMembers}
onChanged={onChanged}
/>,
);
return { onChanged, org };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.session.organizations = [];
mocks.orgApi.listOrgInvites.mockResolvedValue(INVITES);
mocks.orgApi.revokeOrgInvite.mockResolvedValue(undefined);
mocks.orgApi.transferOrgOwnership.mockResolvedValue({
id: "org-1",
name: "شرکت نمونه",
slug: "acme",
owner_user_id: "u-ali",
role: "admin",
});
mocks.orgApi.leaveOrganization.mockResolvedValue(undefined);
});
describe("the sharing section on the members tab", () => {
it("lists only the invitations nobody has accepted yet", async () => {
renderSection();
expect(await screen.findByText("new.hire@acme.test")).toBeInTheDocument();
expect(screen.queryByText("joined@acme.test")).not.toBeInTheDocument();
expect(mocks.orgApi.listOrgInvites).toHaveBeenCalledWith("org-1");
});
it("revokes a pending invitation through the API", async () => {
renderSection();
fireEvent.click(await screen.findByRole("button", { name: "لغو دعوت" }));
const dialog = screen.getByRole("dialog", { name: "لغو دعوت" });
fireEvent.click(within(dialog).getByRole("button", { name: "لغو دعوت" }));
await waitFor(() =>
expect(mocks.orgApi.revokeOrgInvite).toHaveBeenCalledWith("org-1", "inv-1"),
);
await waitFor(() =>
expect(screen.queryByText("new.hire@acme.test")).not.toBeInTheDocument(),
);
});
it("keeps the transfer disabled until the organization slug is typed", async () => {
const { onChanged } = renderSection();
fireEvent.change(screen.getByLabelText("مدیر جدید"), { target: { value: "u-ali" } });
fireEvent.click(screen.getByRole("button", { name: "ادامه و تأیید" }));
const dialog = screen.getByRole("dialog", { name: "تأیید انتقال مالکیت" });
const confirm = within(dialog).getByRole("button", { name: "انتقال مالکیت" });
expect(confirm).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("نامک سازمان"), {
target: { value: "acme-typo" },
});
expect(confirm).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText("نامک سازمان"), {
target: { value: " ACME " },
});
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
await waitFor(() =>
expect(mocks.orgApi.transferOrgOwnership).toHaveBeenCalledWith("org-1", {
new_owner_user_id: "u-ali",
confirm: "ACME",
}),
);
await waitFor(() => expect(mocks.session.refresh).toHaveBeenCalled());
expect(onChanged).toHaveBeenCalled();
});
it("shows the server's refusal as its own message", async () => {
renderSection();
fireEvent.change(screen.getByLabelText("مدیر جدید"), { target: { value: "u-ali" } });
fireEvent.click(screen.getByRole("button", { name: "ادامه و تأیید" }));
const dialog = screen.getByRole("dialog", { name: "تأیید انتقال مالکیت" });
fireEvent.change(within(dialog).getByLabelText("نامک سازمان"), {
target: { value: "acme" },
});
mocks.orgApi.transferOrgOwnership.mockRejectedValueOnce(
new mocks.FakeApiError(400, "validation_error", "مالکیت تنها به عضوی با نقش admin منتقل می‌شود."),
);
fireEvent.click(within(dialog).getByRole("button", { name: "انتقال مالکیت" }));
expect(
await screen.findByText("مالکیت تنها به عضوی با نقش admin منتقل می‌شود."),
).toBeInTheDocument();
});
it("shows no transfer section to an admin", async () => {
renderSection({ role: "admin" });
expect(await screen.findByText("new.hire@acme.test")).toBeInTheDocument();
expect(screen.queryByText("انتقال مالکیت")).not.toBeInTheDocument();
expect(screen.queryByLabelText("مدیر جدید")).not.toBeInTheDocument();
});
it("shows a plain member neither the invitations nor the transfer", () => {
renderSection({ role: "member" }, false);
expect(screen.queryByText("دعوت‌های در انتظار پذیرش")).not.toBeInTheDocument();
expect(screen.queryByText("انتقال مالکیت")).not.toBeInTheDocument();
expect(mocks.orgApi.listOrgInvites).not.toHaveBeenCalled();
// Leaving stays available to every member.
expect(screen.getByRole("button", { name: "ترک سازمان" })).toBeInTheDocument();
});
it("sends an account that leaves its only organization to the wizard", async () => {
renderSection({ role: "member" }, false);
fireEvent.click(screen.getByRole("button", { name: "ترک سازمان" }));
const dialog = screen.getByRole("dialog", { name: "ترک سازمان" });
fireEvent.click(within(dialog).getByRole("button", { name: "ترک سازمان" }));
await waitFor(() => expect(mocks.orgApi.leaveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.router.replace).toHaveBeenCalledWith("/onboarding"));
expect(mocks.session.refresh).toHaveBeenCalled();
});
});