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:
@@ -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="تنزل مدیر به کاربر عادی؟"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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