ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
This commit is contained in:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
+132
View File
@@ -0,0 +1,132 @@
"use client";
/**
* Personal account settings.
*
* Deliberately not inside `/settings`: that page is organization-scoped and
* shows nothing when no organization is selected — which is exactly the state
* of a freshly created manager or an invited user before they join anything.
* Everyone must be able to replace the temporary password someone else chose
* for them, so this lives behind the user menu instead.
*/
import * as React from "react";
import { PLATFORM_ROLE_LABELS } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ErrorState,
Field,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
const MIN_PASSWORD_LENGTH = 8;
export default function AccountPage() {
const { user } = useSession();
const [current, setCurrent] = React.useState("");
const [next, setNext] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [done, setDone] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setDone(false);
if (next !== confirm) {
setError("رمز عبور جدید و تکرار آن یکسان نیستند.");
return;
}
setSubmitting(true);
try {
await api.changePassword({ current_password: current, new_password: next });
setDone(true);
setCurrent("");
setNext("");
setConfirm("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "تغییر رمز عبور ناموفق بود.");
} finally {
setSubmitting(false);
}
};
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">حساب کاربری</h1>
<p className="text-sm text-slate-400">{user?.email}</p>
</header>
<Card>
<CardTitle>نقش شما در سامانه</CardTitle>
<CardHint>
{user?.is_platform_owner
? "شما مالک سامانه هستید: تنها حساب با دسترسی به کنسول مدیریت، ساخت مدیران و انتقال مالکیت."
: user?.is_manager
? "شما مدیر هستید: می‌توانید سازمان بسازید و با دعوت در سازمان مدیران دیگر عضو شوید."
: "شما کاربر عادی هستید: با دعوت به سازمان‌ها می‌پیوندید و داخل آن‌ها کار می‌کنید."}
</CardHint>
<div className="mt-3">
<Badge tone={user?.is_platform_owner ? "info" : user?.is_manager ? "success" : "neutral"}>
{user ? PLATFORM_ROLE_LABELS[user.platform_role] : "—"}
</Badge>
</div>
</Card>
<Card>
<CardTitle>تغییر رمز عبور</CardTitle>
<CardHint>
اگر رمز شما را شخص دیگری (مالک سامانه) تعیین کرده است، از اینجا آن را به
رمزی خصوصی تغییر دهید.
</CardHint>
<form onSubmit={onSubmit} className="mt-4 flex max-w-sm flex-col gap-4">
<Field
label="رمز عبور فعلی"
type="password"
dir="ltr"
autoComplete="current-password"
required
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
<Field
label="رمز عبور جدید"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={next}
onChange={(e) => setNext(e.target.value)}
hint={`حداقل ${MIN_PASSWORD_LENGTH} کاراکتر.`}
/>
<Field
label="تکرار رمز عبور جدید"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{error && <ErrorState title="تغییر رمز ناموفق" message={error} />}
{done && (
<p className="text-xs text-emerald-400">رمز عبور با موفقیت تغییر کرد.</p>
)}
<Button type="submit" loading={submitting}>
تغییر رمز عبور
</Button>
</form>
</Card>
</div>
);
}
+1
View File
@@ -16,6 +16,7 @@ import { useSession } from "@/lib/session";
const SECTIONS = [
{ href: "/admin", label: "نمای کلی" },
{ href: "/admin/users", label: "کاربران و مدیران" },
{ href: "/admin/agy-accounts", label: "اکانت‌های agy" },
{ href: "/admin/models", label: "مدل‌ها" },
{ href: "/admin/routing", label: "مسیریابی" },
+5
View File
@@ -83,6 +83,11 @@ export default function AdminOverviewPage() {
<div className="grid gap-4 lg:grid-cols-2">
{[
{
title: "کاربران و مدیران",
hint: "مالک سامانه دقیقاً یکی است؛ فقط او مدیر می‌سازد و می‌تواند مالکیت را منتقل کند.",
path: "/admin/users",
},
{
title: "اکانت‌های agy",
hint: "ورود با Google و پول چرخشی اکانت‌ها. agy تنها ارائه‌دهندهٔ سامانه است.",
+441
View File
@@ -0,0 +1,441 @@
"use client";
/**
* Platform owner console: accounts.
*
* The whole hierarchy lives on this screen — there is exactly one owner, the
* owner mints the manager accounts (the tier allowed to create organizations),
* and every other account joins an organization by invitation. The destructive
* actions (demote, delete, transfer ownership) each say what will happen to the
* organizations involved *before* they run, because the API moves them to the
* owner and there is no undo.
*/
import * as React from "react";
import type { AdminUser, PlatformRole } from "@agy/contracts";
import { PLATFORM_ROLE_LABELS } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
Stat,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
const ROLE_TONES: Record<PlatformRole, "info" | "success" | "neutral"> = {
owner: "info",
manager: "success",
user: "neutral",
};
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export default function AdminUsersPage() {
const [users, setUsers] = React.useState<AdminUser[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [busy, setBusy] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
const [newEmail, setNewEmail] = React.useState("");
const [newName, setNewName] = React.useState("");
const [newRole, setNewRole] = React.useState<"manager" | "user">("manager");
const [creating, setCreating] = React.useState(false);
const [createError, setCreateError] = React.useState<string | null>(null);
//: The one-time password, shown in a modal until dismissed.
const [tempPassword, setTempPassword] = React.useState<{ email: string; value: string } | null>(
null,
);
const [confirm, setConfirm] = React.useState<
| { kind: "demote"; user: AdminUser }
| { kind: "delete"; user: AdminUser }
| { kind: "transfer"; user: AdminUser }
| null
>(null);
const toast = React.useCallback((title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
}, []);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setUsers(await api.adminUsers());
} catch (err) {
setError(errorMessage(err, "دریافت فهرست کاربران ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const onCreate = async (event: React.FormEvent) => {
event.preventDefault();
setCreating(true);
setCreateError(null);
try {
const created = await api.adminCreateUser({
email: newEmail.trim(),
name: newName.trim(),
platform_role: newRole,
});
setTempPassword({ email: created.user.email, value: created.temp_password });
setCreateOpen(false);
setNewEmail("");
setNewName("");
setNewRole("manager");
await load();
} catch (err) {
setCreateError(errorMessage(err, "ساخت کاربر ناموفق بود."));
} finally {
setCreating(false);
}
};
const onConfirm = async () => {
if (!confirm) return;
const { kind, user } = confirm;
setConfirm(null);
setBusy(user.id);
try {
if (kind === "demote") {
await api.adminUpdateUser(user.id, { platform_role: "user" });
toast(
"کاربر به سطح عادی تنزل یافت",
"success",
user.owned_organizations > 0
? `${user.owned_organizations} سازمان به مالک سامانه منتقل شد.`
: undefined,
);
} else if (kind === "delete") {
const result = await api.adminDeleteUser(user.id);
toast(
"حساب حذف شد",
"success",
result.transferred_organizations.length > 0
? `${result.transferred_organizations.length} سازمان به مالک سامانه منتقل شد.`
: undefined,
);
} else {
await api.adminTransferOwnership(user.id);
toast("مالکیت سامانه منتقل شد", "success", "حساب شما اکنون مدیر است.");
}
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const onToggleActive = async (user: AdminUser) => {
setBusy(user.id);
try {
await api.adminUpdateUser(user.id, { is_active: !user.is_active });
toast(user.is_active ? "حساب غیرفعال شد" : "حساب فعال شد");
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const onResetPassword = async (user: AdminUser) => {
setBusy(user.id);
try {
const result = await api.adminResetUserPassword(user.id);
setTempPassword({ email: user.email, value: result.temp_password });
} catch (err) {
toast("تغییر رمز ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
if (loading) return <LoadingState label="در حال دریافت کاربران…" />;
if (error) return <ErrorState message={error} onRetry={load} />;
const owner = users.find((user) => user.is_platform_owner) ?? null;
const managers = users.filter((user) => user.platform_role === "manager");
const plainUsers = users.filter((user) => user.platform_role === "user");
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کاربران و مدیران</h1>
<p className="text-sm text-slate-400">
مالک سامانه دقیقاً یکی است و فقط او می‌تواند مدیر بسازد. مدیران سازمان
خودشان را می‌سازند؛ کاربران عادی با دعوت به سازمان‌ها می‌پیوندند.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="مالک سامانه" value={owner ? 1 : 0} />
<Stat label="مدیران" value={managers.length} />
<Stat label="کاربران عادی" value={plainUsers.length} />
</div>
{users.length === 0 ? (
<EmptyState title="کاربری وجود ندارد" description="هنوز هیچ حسابی ساخته نشده است." />
) : (
<Card>
<CardTitle>فهرست حساب‌ها</CardTitle>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">ایمیل</th>
<th className="px-2 py-2 text-right font-medium">نام</th>
<th className="px-2 py-2 text-right font-medium">نقش پلتفرم</th>
<th className="px-2 py-2 text-right font-medium">سازمان‌ها</th>
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
<th className="px-2 py-2 text-right font-medium">عملیات</th>
</tr>
</thead>
<tbody className="anim-stagger">
{users.map((user) => (
<tr key={user.id} className="border-b border-surface-border/60">
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">{user.email}</td>
<td className="px-2 py-2 text-slate-200">{user.name}</td>
<td className="px-2 py-2">
<Badge tone={ROLE_TONES[user.platform_role]}>
{PLATFORM_ROLE_LABELS[user.platform_role]}
</Badge>
</td>
<td className="px-2 py-2">
<Badge tone="neutral">{user.owned_organizations}</Badge>
</td>
<td className="px-2 py-2">
<Badge tone={user.is_active ? "success" : "warning"}>
{user.is_active ? "فعال" : "غیرفعال"}
</Badge>
</td>
<td className="px-2 py-2">
{user.is_platform_owner ? (
<span className="text-xs text-slate-500">
حساب مالک سامانه قابل تغییر نیست
</span>
) : (
<div className="flex flex-wrap gap-1.5">
{user.platform_role === "manager" ? (
<Button
size="sm"
variant="secondary"
disabled={busy === user.id}
onClick={() => setConfirm({ kind: "demote", user })}
>
تنزل به کاربر
</Button>
) : (
<Button
size="sm"
variant="secondary"
disabled={busy === user.id}
onClick={async () => {
setBusy(user.id);
try {
await api.adminUpdateUser(user.id, {
platform_role: "manager",
});
toast("کاربر به مدیر ارتقا یافت");
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
}}
>
ارتقا به مدیر
</Button>
)}
<Button
size="sm"
variant="ghost"
disabled={busy === user.id}
onClick={() => void onToggleActive(user)}
>
{user.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy === user.id}
onClick={() => void onResetPassword(user)}
>
رمز جدید
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy === user.id}
onClick={() => setConfirm({ kind: "transfer", user })}
>
انتقال مالکیت
</Button>
<Button
size="sm"
variant="danger"
disabled={busy === user.id}
onClick={() => setConfirm({ kind: "delete", user })}
>
حذف
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
<Modal
open={createOpen}
title="ساخت کاربر"
description="رمز موقت فقط یک‌بار نمایش داده می‌شود؛ کاربر باید آن را در تنظیمات تغییر دهد."
onClose={() => setCreateOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" form="create-user-form" loading={creating}>
ساخت
</Button>
</>
}
>
<form id="create-user-form" onSubmit={onCreate} className="flex flex-col gap-4">
<Field
label="ایمیل"
type="email"
dir="ltr"
required
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
<Field
label="نام"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="مثلاً مریم رضایی"
/>
<Select
label="نقش پلتفرم"
value={newRole}
onChange={(e) => setNewRole(e.target.value as "manager" | "user")}
options={[
{ value: "manager", label: "مدیر — می‌تواند سازمان بسازد" },
{ value: "user", label: "کاربر عادی — فقط با دعوت عضو می‌شود" },
]}
/>
{createError && <ErrorState title="ساخت کاربر ناموفق" message={createError} />}
</form>
</Modal>
<Modal
open={tempPassword !== null}
title="رمز موقت"
description="این رمز فقط همین یک‌بار نمایش داده می‌شود. آن را به‌صورت امن به کاربر برسانید."
onClose={() => setTempPassword(null)}
footer={
<>
<Button
variant="secondary"
onClick={() => {
if (tempPassword) void navigator.clipboard?.writeText(tempPassword.value);
toast("رمز موقت کپی شد");
}}
>
کپی رمز
</Button>
<Button onClick={() => setTempPassword(null)}>متوجه شدم</Button>
</>
}
>
<p className="text-xs text-slate-400">
حساب: <span className="ltr-inline text-slate-200">{tempPassword?.email}</span>
</p>
<p className="ltr-inline mt-3 rounded-lg border border-surface-border bg-surface p-3 font-mono text-sm text-slate-100">
{tempPassword?.value}
</p>
</Modal>
<ConfirmDialog
open={confirm?.kind === "demote"}
title="تنزل مدیر به کاربر عادی؟"
description={
confirm?.kind === "demote"
? confirm.user.owned_organizations > 0
? `این مدیر ${confirm.user.owned_organizations} سازمان دارد؛ همهٔ آن‌ها به مالک سامانه منتقل می‌شوند و او دیگر نمی‌تواند سازمان بسازد.`
: "این کاربر دیگر نمی‌تواند سازمان بسازد."
: undefined
}
confirmLabel="تنزل"
onConfirm={() => void onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={confirm?.kind === "delete"}
title="حذف حساب؟"
description={
confirm?.kind === "delete"
? confirm.user.owned_organizations > 0
? `این حساب ${confirm.user.owned_organizations} سازمان دارد؛ سازمان‌ها و داده‌هایشان حفظ می‌شوند و به مالک سامانه منتقل می‌شوند. عضویت‌های این حساب حذف می‌شوند.`
: "این حساب و همهٔ عضویت‌هایش حذف می‌شوند."
: undefined
}
confirmLabel="حذف حساب"
onConfirm={() => void onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={confirm?.kind === "transfer"}
title="انتقال مالکیت سامانه؟"
description={
confirm?.kind === "transfer"
? `مالکیت کل سامانه به ${confirm.user.email} منتقل می‌شود و حساب شما به مدیر تنزل می‌یابد. دسترسی کنسول مالک سامانه از شما گرفته می‌شود.`
: undefined
}
confirmLabel="انتقال مالکیت"
onConfirm={() => void onConfirm()}
onCancel={() => setConfirm(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
+116 -63
View File
@@ -1,12 +1,14 @@
"use client";
/**
* Invite redemption screen.
* Invite redemption screen — the only door into the product now that signup is
* closed.
*
* Lives outside the ``(app)`` group on purpose: the invitee is not a member of
* anything yet, so the authenticated shell would bounce them to /login before
* they could accept. Here we render a standalone card, and only ask for a
* session when there is actually an invite to redeem.
* they could accept. The preview call is anonymous, which is what lets a
* brand-new invitee see who invited them and then create their account (by
* choosing a password) without ever visiting a signup page.
*/
import Link from "next/link";
@@ -20,6 +22,7 @@ import {
CardHint,
CardTitle,
ErrorState,
Field,
LoadingState,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
@@ -33,6 +36,8 @@ const ROLE_LABELS: Record<string, string> = {
viewer: "بیننده",
};
const MIN_PASSWORD_LENGTH = 8;
function Centered({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center p-5">
@@ -45,26 +50,29 @@ function AcceptInviteCard() {
const router = useRouter();
const params = useSearchParams();
const token = params.get("token") ?? "";
const { ready, user, refresh, setActiveOrganization } = useSession();
const { ready, user, login, refresh, setActiveOrganization } = useSession();
const [preview, setPreview] = React.useState<InvitePreview | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [accepting, setAccepting] = React.useState(false);
const [password, setPassword] = React.useState("");
// The invitee may not have an account yet; after they sign in or sign up we
// send them straight back here so the token is not lost.
// The invitee may not have an account yet; after they sign in we send them
// straight back here so the token is not lost.
const nextPath = `/accept-invite?token=${encodeURIComponent(token)}`;
React.useEffect(() => {
if (!ready) return;
if (!token || !user) {
if (!token) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
// Deliberately called even without a session: a brand-new invitee must be
// able to see the organization and whether they already have an account.
api
.previewInvite(token)
.then((result) => {
@@ -72,7 +80,9 @@ function AcceptInviteCard() {
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات دعوت‌نامه ناموفق بود.");
setError(
err instanceof ApiError ? err.message : "دریافت اطلاعات دعوت‌نامه ناموفق بود.",
);
}
})
.finally(() => {
@@ -83,16 +93,24 @@ function AcceptInviteCard() {
};
}, [ready, token, user]);
const finish = React.useCallback(
async (organizationId: string) => {
// Land the invitee inside the organization they just joined rather than
// whichever tenant happened to be active before.
setActiveOrganization(organizationId);
await refresh();
router.replace("/dashboard");
},
[refresh, router, setActiveOrganization],
);
/** Signed-in invitee: the account exists, so no password is needed. */
const onAccept = async () => {
setError(null);
setAccepting(true);
try {
const result = await api.acceptInvite(token);
// Land the invitee inside the organization they just joined rather than
// whichever tenant happened to be active before.
setActiveOrganization(result.organization_id);
await refresh();
router.replace("/dashboard");
await finish(result.organization_id);
} catch (err) {
setError(err instanceof ApiError ? err.message : "پذیرش دعوت‌نامه ناموفق بود.");
} finally {
@@ -100,6 +118,25 @@ function AcceptInviteCard() {
}
};
/** Anonymous invitee with no account: the link creates the account. */
const onCreateAndAccept = async (event: React.FormEvent) => {
event.preventDefault();
if (!preview) return;
setError(null);
setAccepting(true);
try {
const result = await api.acceptInvite(token, password);
// 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);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت حساب ناموفق بود.");
} finally {
setAccepting(false);
}
};
if (!ready) {
return (
<Centered>
@@ -119,32 +156,8 @@ function AcceptInviteCard() {
توکن دعوت در آدرس وجود ندارد. لینک کامل را از فرستنده دوباره بگیرید.
</CardHint>
<div className="mt-5">
<Link href="/dashboard" className="text-xs text-accent hover:underline">
بازگشت به داشبورد
</Link>
</div>
</Card>
</Centered>
);
}
if (!user) {
return (
<Centered>
<Card className="anim-rise">
<CardTitle>دعوت به یک سازمان</CardTitle>
<CardHint>
برای پذیرش این دعوت باید وارد شوید. اگر هنوز حساب ندارید، با همان ایمیلی که
دعوت‌نامه برایش ارسال شده ثبت‌نام کنید.
</CardHint>
<div className="mt-5 flex gap-2">
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="flex-1">
<Button className="w-full">ورود</Button>
</Link>
<Link href={`/signup?next=${encodeURIComponent(nextPath)}`} className="flex-1">
<Button variant="secondary" className="w-full">
ثبت‌نام
</Button>
<Link href="/login" className="text-xs text-accent hover:underline">
ورود به حساب
</Link>
</div>
</Card>
@@ -162,17 +175,14 @@ function AcceptInviteCard() {
);
}
if (error || !preview) {
if (error && !preview) {
return (
<Centered>
<Card className="anim-rise">
<ErrorState
title="دعوت‌نامه قابل استفاده نیست"
message={error ?? "اطلاعات دعوت‌نامه در دسترس نیست."}
/>
<ErrorState title="دعوت‌نامه قابل استفاده نیست" message={error} />
<div className="mt-5">
<Link href="/dashboard" className="text-xs text-accent hover:underline">
بازگشت به داشبورد
<Link href="/login" className="text-xs text-accent hover:underline">
ورود به حساب
</Link>
</div>
</Card>
@@ -180,35 +190,78 @@ function AcceptInviteCard() {
);
}
if (!preview) return null;
const roleLabel = ROLE_LABELS[preview.role] ?? preview.role;
const signedInWithInvitedEmail =
user !== null && user.email.trim().toLowerCase() === preview.email.trim().toLowerCase();
return (
<Centered>
<Card className="anim-rise">
<CardTitle>دعوت به {preview.organization_name}</CardTitle>
<CardHint>
برای <span className="ltr-inline">{preview.email}</span> با نقش{" "}
{ROLE_LABELS[preview.role] ?? preview.role} دعوت شده‌اید.
برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
شده‌اید.
</CardHint>
<div className="mt-4 flex items-center gap-2">
<Badge tone="info">{ROLE_LABELS[preview.role] ?? preview.role}</Badge>
<Badge tone="info">{roleLabel}</Badge>
{preview.already_member && <Badge tone="neutral">شما از قبل عضو هستید</Badge>}
</div>
<div className="mt-5">
<Button className="w-full" loading={accepting} onClick={onAccept}>
{preview.already_member ? "ورود به سازمان" : "پذیرش دعوت"}
</Button>
</div>
{signedInWithInvitedEmail ? (
<div className="mt-5">
<Button className="w-full" loading={accepting} onClick={onAccept}>
{preview.already_member ? "ورود به سازمان" : "پذیرش دعوت"}
</Button>
</div>
) : !user && !preview.has_account ? (
// 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">
<Field
label="رمز عبور برای ساخت حساب"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={(e) => setPassword(e.target.value)}
hint={`حداقل ${MIN_PASSWORD_LENGTH} کاراکتر.`}
/>
{error && <ErrorState title="ساخت حساب ناموفق" message={error} />}
<Button type="submit" loading={accepting}>
ساخت حساب و پیوستن
</Button>
</form>
) : (
// An account already exists for the invited email: the link must
// never act as a password reset, so the invitee logs in first.
<div className="mt-5 flex flex-col gap-3">
<CardHint>
برای این ایمیل از قبل حساب ساخته شده است. برای پذیرش دعوت با همان حساب
وارد شوید.
</CardHint>
{error && <ErrorState title="پذیرش دعوت ناموفق" message={error} />}
<Link href={`/login?next=${encodeURIComponent(nextPath)}`}>
<Button className="w-full">ورود و پذیرش دعوت</Button>
</Link>
</div>
)}
<p className="mt-4 text-xs text-slate-400">
با حساب دیگری وارد شده‌اید؟{" "}
<Link
href={`/login?next=${encodeURIComponent(nextPath)}`}
className="text-accent hover:underline"
>
تعویض حساب
</Link>
</p>
{user && !signedInWithInvitedEmail && (
<p className="mt-4 text-xs text-slate-400">
با حساب دیگری وارد شده‌اید؟{" "}
<Link
href={`/login?next=${encodeURIComponent(nextPath)}`}
className="text-accent hover:underline"
>
تعویض حساب
</Link>
</p>
)}
</Card>
</Centered>
);
+2 -15
View File
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import * as React from "react";
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
@@ -25,13 +24,6 @@ export default function LoginPage() {
const [password, setPassword] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
// Read once on mount so the "no account?" link carries the invite token along.
const [nextPath, setNextPath] = React.useState<string | null>(null);
React.useEffect(() => {
const next = new URLSearchParams(window.location.search).get("next");
setNextPath(next && next.startsWith("/") && !next.startsWith("//") ? next : null);
}, []);
React.useEffect(() => {
if (ready && user) router.replace(nextDestination("/dashboard"));
@@ -85,13 +77,8 @@ export default function LoginPage() {
</form>
<p className="mt-4 text-xs text-slate-400">
حساب ندارید؟{" "}
<Link
href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : "/signup"}
className="text-accent hover:underline"
>
ثبت‌نام کنید
</Link>
حساب ندارید؟ دسترسی فقط با دعوت انجام می‌شود؛ لینک دعوت را از مدیر
سازمان خود بگیرید و آن را باز کنید.
</p>
</Card>
</div>
+30 -1
View File
@@ -2,11 +2,18 @@
import { useRouter } from "next/navigation";
import * as React from "react";
import { canCreateOrganization } from "@agy/contracts";
import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
/** First-run flow: create the user's first organization. */
/**
* First-run flow: create an organization.
*
* Only the platform owner and the managers they create may do this. A plain
* account reaches this screen with nothing to do but wait for an invite, and
* saying so is more honest than showing a form the API would reject.
*/
export default function OnboardingPage() {
const router = useRouter();
const { ready, user, organizations, refresh } = useSession();
@@ -41,6 +48,28 @@ export default function OnboardingPage() {
);
}
if (user && !canCreateOrganization(user.platform_role)) {
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-md">
<CardTitle>در انتظار دعوت</CardTitle>
<CardHint>
حساب شما در حال حاضر عضو هیچ سازمانی نیست و ساخت سازمان برای کاربران
عادی فعال نیست. برای شروع کار، از مدیر سازمان خود بخواهید لینک دعوت
برایتان بفرستد.
</CardHint>
{organizations.length > 0 && (
<div className="mt-5">
<Button className="w-full" onClick={() => router.replace("/dashboard")}>
بازگشت به داشبورد
</Button>
</div>
)}
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-md">
-104
View File
@@ -1,104 +0,0 @@
"use client";
import Link from "next/link";
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 { useSession } from "@/lib/session";
/**
* Where to land after signing up. Only same-origin paths are honoured, so a
* crafted `?next=https://evil.example` cannot turn the form into an open
* redirect.
*/
function nextDestination(fallback: string): string {
if (typeof window === "undefined") return fallback;
const next = new URLSearchParams(window.location.search).get("next");
return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback;
}
export default function SignupPage() {
const router = useRouter();
const { signup } = useSession();
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
// Read once on mount so the "already registered?" link carries the invite token.
const [nextPath, setNextPath] = React.useState<string | null>(null);
React.useEffect(() => {
const next = new URLSearchParams(window.location.search).get("next");
setNextPath(next && next.startsWith("/") && !next.startsWith("//") ? next : null);
}, []);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
await signup(email, password, name);
router.replace(nextDestination("/onboarding"));
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت‌نام ناموفق بود.");
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-sm">
<CardTitle>ساخت حساب</CardTitle>
<CardHint>پس از ثبت‌نام، اولین سازمان خود را می‌سازید.</CardHint>
<form onSubmit={onSubmit} className="mt-5 flex flex-col gap-4">
<Field
label="نام"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="نام شما"
/>
<Field
label="ایمیل"
type="email"
dir="ltr"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label="رمز عبور"
type="password"
dir="ltr"
autoComplete="new-password"
required
minLength={8}
hint="حداقل ۸ کاراکتر."
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <ErrorState title="ثبت‌نام ناموفق" message={error} />}
<Button type="submit" loading={submitting}>
ثبت‌نام
</Button>
</form>
<p className="mt-4 text-xs text-slate-400">
قبلاً ثبت‌نام کرده‌اید؟{" "}
<Link
href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : "/login"}
className="text-accent hover:underline"
>
ورود
</Link>
</p>
</Card>
</div>
);
}
+17 -7
View File
@@ -215,13 +215,15 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</button>
))}
<div className="my-1 border-t border-surface-border" />
<Link
href="/onboarding"
className="transition-colors block rounded-md px-2 py-2 text-xs text-slate-300 hover:bg-surface-raised"
onClick={() => setMenuOpen(false)}
>
ساخت سازمان جدید
</Link>
{(user?.is_platform_owner || user?.is_manager) && (
<Link
href="/onboarding"
className="transition-colors block rounded-md px-2 py-2 text-xs text-slate-300 hover:bg-surface-raised"
onClick={() => setMenuOpen(false)}
>
ساخت سازمان جدید
</Link>
)}
</div>
)}
</div>
@@ -245,6 +247,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{user?.name || user?.email}
</span>
{user?.is_platform_owner && <Badge tone="info">مالک سامانه</Badge>}
{user?.is_manager && <Badge tone="success">مدیر</Badge>}
</button>
{userMenuOpen && (
<div className="anim-slide-up absolute end-0 top-11 z-40 w-56 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
@@ -255,6 +258,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<p className="truncate text-xs text-slate-500">{user?.email}</p>
</div>
<div className="my-1 border-t border-surface-border" />
<Link
href="/account"
onClick={() => setUserMenuOpen(false)}
className="transition-colors block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised"
>
حساب کاربری و رمز عبور
</Link>
<Link
href="/settings"
onClick={() => setUserMenuOpen(false)}
+38 -8
View File
@@ -148,17 +148,16 @@ async function fetchHealth<T>(
export const api = {
health: () => apiRequest<{ status: string }>("/health"),
readiness: () => apiRequest<import("@agy/contracts").ReadinessResponse>("/health/ready"),
signup: (body: { email: string; password: string; name?: string }) =>
apiRequest<import("@agy/contracts").TokenResponse>("/api/auth/signup", {
method: "POST",
body,
}),
// There is no `signup`: accounts are created by the platform owner or by
// redeeming an invite link (see `acceptInvite`).
login: (body: { email: string; password: string }) =>
apiRequest<import("@agy/contracts").TokenResponse>("/api/auth/login", {
method: "POST",
body,
}),
logout: () => apiRequest<void>("/api/auth/logout", { method: "POST" }),
changePassword: (body: { current_password: string; new_password: string }) =>
apiRequest<void>("/api/auth/change-password", { method: "POST", body }),
me: () => apiRequest<import("@agy/contracts").MeResponse>("/api/auth/me"),
listOrganizations: () =>
apiRequest<import("@agy/contracts").OrganizationWithRole[]>("/api/organizations"),
@@ -175,15 +174,17 @@ export const api = {
body,
}),
// Redeeming an invite is intentionally *not* org-scoped: the invitee is not a
// member yet, so the token is what authorizes the call.
// member yet, so the token is what authorizes the call. `password` is only
// sent when the invitee has no account yet — it becomes their password and
// the account is created for the invited email.
previewInvite: (token: string) =>
apiRequest<import("@agy/contracts").InvitePreview>(
`/api/invites/${encodeURIComponent(token)}`,
),
acceptInvite: (token: string) =>
acceptInvite: (token: string, password?: string) =>
apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", {
method: "POST",
body: { token },
body: password ? { token, password } : { token },
}),
updateMember: (
orgId: string,
@@ -567,6 +568,35 @@ export const api = {
{ method: "POST" },
),
// --- accounts (platform owner only) --------------------------------------
// The owner creates every manager account; a plain user joins by invitation.
adminUsers: () => apiRequest<import("@agy/contracts").AdminUser[]>("/api/admin/users"),
adminCreateUser: (body: import("@agy/contracts").CreateUserRequest) =>
apiRequest<import("@agy/contracts").AdminUserCreated>("/api/admin/users", {
method: "POST",
body,
}),
adminUpdateUser: (userId: string, body: import("@agy/contracts").UpdateUserRequest) =>
apiRequest<import("@agy/contracts").AdminUser>(
`/api/admin/users/${encodeURIComponent(userId)}`,
{ method: "PATCH", body },
),
adminDeleteUser: (userId: string) =>
apiRequest<import("@agy/contracts").DeleteUserResult>(
`/api/admin/users/${encodeURIComponent(userId)}`,
{ method: "DELETE" },
),
adminResetUserPassword: (userId: string) =>
apiRequest<import("@agy/contracts").AdminUserCreated>(
`/api/admin/users/${encodeURIComponent(userId)}/reset-password`,
{ method: "POST" },
),
adminTransferOwnership: (userId: string) =>
apiRequest<import("@agy/contracts").AdminUser>("/api/admin/transfer-ownership", {
method: "POST",
body: { user_id: userId },
}),
// --- error console --------------------------------------------------------
// The console exists so a failure that already happened can be diagnosed
// without reproducing it. Grouped by fingerprint, so a bug that fired a
+1
View File
@@ -32,6 +32,7 @@ export const NAV_ITEMS: NavItem[] = [
*/
export const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
{ href: "/admin/users", label: "کاربران و مدیران", hint: "ساخت مدیر و نقش‌ها" },
{ href: "/admin/agy-accounts", label: "اکانت‌های agy", hint: "ورود و پول چرخشی" },
{ href: "/admin/models", label: "مدل‌ها", hint: "قابلیت و قیمت" },
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
+1 -13
View File
@@ -21,7 +21,6 @@ interface SessionValue {
organizations: OrganizationWithRole[];
activeOrganization: OrganizationWithRole | null;
login: (email: string, password: string) => Promise<void>;
signup: (email: string, password: string, name: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
setActiveOrganization: (orgId: string) => void;
@@ -102,16 +101,6 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
[applyMe],
);
const signup = React.useCallback(
async (email: string, password: string, name: string) => {
const result = await api.signup({ email, password, name });
setStoredToken(result.access_token);
setToken(result.access_token);
applyMe(await api.me());
},
[applyMe],
);
const logout = React.useCallback(async () => {
try {
await api.logout();
@@ -144,12 +133,11 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
organizations,
activeOrganization,
login,
signup,
logout,
refresh,
setActiveOrganization,
}),
[ready, token, user, organizations, activeOrganization, login, signup, logout, refresh, setActiveOrganization],
[ready, token, user, organizations, activeOrganization, login, logout, refresh, setActiveOrganization],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
+66 -32
View File
@@ -1,7 +1,7 @@
/**
* The accept-invite screen is the only place an invited user can turn a link
* into a membership, so its states matter: missing token, signed out, preview
* failure, and the happy path.
* The accept-invite screen is now the *only* door into the product: signup is
* closed, so the link must both preview the invitation to a stranger and let
* them create their account by choosing a password. Every state matters.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
@@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => {
session: {
ready: true,
user: null as { id: string; email: string } | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
@@ -65,6 +66,16 @@ vi.mock("@/lib/api", () => ({
import AcceptInvitePage from "@/app/accept-invite/page";
const PREVIEW = {
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "member",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
has_account: false,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.token = "tok-123";
@@ -73,12 +84,56 @@ beforeEach(() => {
});
describe("accept-invite", () => {
it("asks a signed-out visitor to authenticate and keeps the token in the link", () => {
it("previews the invite to a signed-out stranger instead of bouncing to login", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
render(<AcceptInvitePage />);
expect(screen.getByText("دعوت به یک سازمان")).toBeInTheDocument();
const login = screen.getByRole("link", { name: "ورود" });
// Losing the token here would strand the invitee on the dashboard.
// The whole point of the closed-signup flow: a brand-new invitee sees who
// invited them without having an account yet.
expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
});
it("offers a set-password form when no account exists for the invited email", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
render(<AcceptInvitePage />);
expect(await screen.findByLabelText(/رمز عبور برای ساخت حساب/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "ساخت حساب و پیوستن" })).toBeInTheDocument();
});
it("creates the account, signs in, and joins on submit", async () => {
mocks.previewInvite.mockResolvedValue(PREVIEW);
mocks.acceptInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
role: "member",
});
render(<AcceptInvitePage />);
fireEvent.change(await screen.findByLabelText(/رمز عبور برای ساخت حساب/), {
target: { value: "password123" },
});
fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" }));
await waitFor(() =>
expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123"),
);
// The password just chosen becomes the login credential, so the invitee
// lands inside the app rather than back at the login screen.
await waitFor(() =>
expect(mocks.session.login).toHaveBeenCalledWith("newbie@example.com", "password123"),
);
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
});
it("asks an invitee whose email already has an account to log in first", async () => {
mocks.previewInvite.mockResolvedValue({ ...PREVIEW, has_account: true });
render(<AcceptInvitePage />);
// A leaked link must never act as a password reset.
const login = await screen.findByRole("link", { name: "ورود و پذیرش دعوت" });
expect(decodeURIComponent(login.getAttribute("href") ?? "")).toBe(
"/login?next=/accept-invite?token=tok-123",
);
@@ -86,36 +141,23 @@ describe("accept-invite", () => {
it("shows the organization and role to the signed-in invitee", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "manager",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
});
mocks.previewInvite.mockResolvedValue({ ...PREVIEW, role: "manager", has_account: true });
render(<AcceptInvitePage />);
expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "پذیرش دعوت" })).toBeInTheDocument();
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
});
it("surfaces the server's reason when the invite is unusable", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockRejectedValue(
new mocks.FakeApiError(
403,
"permission_denied",
"این دعوت‌نامه برای ایمیل دیگری صادر شده است.",
),
new mocks.FakeApiError(409, "conflict", "این دعوت‌نامه قبلاً پذیرفته شده است."),
);
render(<AcceptInvitePage />);
expect(await screen.findByText("دعوت‌نامه قابل استفاده نیست")).toBeInTheDocument();
expect(screen.getByText(/برای ایمیل دیگری/)).toBeInTheDocument();
expect(screen.getByText(/قبلاً پذیرفته/)).toBeInTheDocument();
});
it("reports a missing token instead of calling the API", () => {
@@ -128,14 +170,7 @@ describe("accept-invite", () => {
it("activates the joined organization and redirects after accepting", async () => {
mocks.session.user = { id: "u1", email: "newbie@example.com" };
mocks.previewInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
email: "newbie@example.com",
role: "member",
expires_at: "2026-01-01T00:00:00Z",
already_member: false,
});
mocks.previewInvite.mockResolvedValue({ ...PREVIEW, has_account: true });
mocks.acceptInvite.mockResolvedValue({
organization_id: "org-1",
organization_name: "Acme",
@@ -146,7 +181,6 @@ describe("accept-invite", () => {
fireEvent.click(await screen.findByRole("button", { name: "پذیرش دعوت" }));
await waitFor(() => expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123"));
// Landing in the new tenant, not whichever one was active before.
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
});