ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمانهای مدیریتشده
- 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "مسیریابی" },
|
||||
|
||||
@@ -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 تنها ارائهدهندهٔ سامانه است.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user