Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants. Co-authored-by: Cursor <cursoragent@cursor.com>
224 lines
7.1 KiB
TypeScript
224 lines
7.1 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||
import { getStoredToken } from "@/lib/auth";
|
||
import { identityApi, type AdminUser } from "@/lib/identity";
|
||
import { normalizePhone } from "@/lib/phone";
|
||
import {
|
||
AdminPageHeader,
|
||
Banner,
|
||
Button,
|
||
EmptyState,
|
||
Modal,
|
||
StatusBadge,
|
||
TextField,
|
||
} from "@/components/admin/controls";
|
||
|
||
export default function AdminUsersPage() {
|
||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
const token = getStoredToken();
|
||
if (!token) {
|
||
setError("توکن یافت نشد.");
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const rows = await identityApi.listUsers(token, { limit: 100 });
|
||
setUsers(rows);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "خطا در بارگذاری کاربران");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<AdminPageHeader
|
||
title="مدیریت کاربران"
|
||
subtitle="کاربران ثبتشده در هویت مرکزی"
|
||
action={<Button onClick={() => setModalOpen(true)}>کاربر جدید</Button>}
|
||
/>
|
||
|
||
{error && <Banner variant="error">{error}</Banner>}
|
||
|
||
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
|
||
{loading ? (
|
||
<EmptyState message="در حال بارگذاری..." />
|
||
) : users.length === 0 ? (
|
||
<EmptyState message="هنوز کاربری ثبت نشده است." />
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[720px] text-sm">
|
||
<thead className="bg-gray-50 text-right">
|
||
<tr>
|
||
<th className="px-4 py-3 font-semibold text-secondary">نام</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">نام کاربری</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">موبایل</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">ایمیل</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">وضعیت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u) => (
|
||
<tr key={u.id} className="border-t border-gray-100">
|
||
<td className="px-4 py-3 font-medium text-secondary">
|
||
{u.display_name || u.username || "بدون نام"}
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-gray-600" dir="ltr">
|
||
{u.username || "—"}
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-gray-600" dir="ltr">
|
||
{u.mobile || "—"}
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-gray-600" dir="ltr">
|
||
{u.email}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
<StatusBadge status={u.status} />
|
||
<StatusBadge
|
||
status={u.mobile_verified ? "active" : "pending_activation"}
|
||
/>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<CreateUserModal
|
||
open={modalOpen}
|
||
onClose={() => setModalOpen(false)}
|
||
onCreated={() => {
|
||
setModalOpen(false);
|
||
void load();
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CreateUserModal({
|
||
open,
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onCreated: () => void;
|
||
}) {
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [username, setUsername] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [mobile, setMobile] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
const handleSubmit = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
setError("");
|
||
const token = getStoredToken();
|
||
if (!token) {
|
||
setError("توکن یافت نشد.");
|
||
return;
|
||
}
|
||
if (password.length < 8) {
|
||
setError("رمز عبور باید حداقل ۸ کاراکتر باشد.");
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
await identityApi.createUser(token, {
|
||
email: email.trim(),
|
||
username: username.trim(),
|
||
password,
|
||
mobile: normalizePhone(mobile),
|
||
display_name: displayName.trim() || undefined,
|
||
});
|
||
setDisplayName("");
|
||
setUsername("");
|
||
setEmail("");
|
||
setMobile("");
|
||
setPassword("");
|
||
onCreated();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "ایجاد کاربر ناموفق بود");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal open={open} title="ایجاد کاربر جدید" onClose={onClose}>
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<TextField label="نام نمایشی" value={displayName} onValue={setDisplayName} disabled={saving} />
|
||
<TextField
|
||
label="نام کاربری"
|
||
required
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={username}
|
||
onValue={setUsername}
|
||
disabled={saving}
|
||
minLength={3}
|
||
/>
|
||
<TextField
|
||
label="ایمیل"
|
||
required
|
||
type="email"
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={email}
|
||
onValue={setEmail}
|
||
disabled={saving}
|
||
/>
|
||
<TextField
|
||
label="موبایل"
|
||
required
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={mobile}
|
||
onValue={setMobile}
|
||
disabled={saving}
|
||
placeholder="09xxxxxxxxx"
|
||
/>
|
||
<TextField
|
||
label="رمز عبور"
|
||
required
|
||
type="password"
|
||
dir="ltr"
|
||
value={password}
|
||
onValue={setPassword}
|
||
disabled={saving}
|
||
hint="حداقل ۸ کاراکتر"
|
||
/>
|
||
{error && <Banner variant="error">{error}</Banner>}
|
||
<div className="flex justify-end gap-3 pt-2">
|
||
<Button variant="outline" onClick={onClose} type="button">
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" loading={saving} loadingText="در حال ایجاد...">
|
||
ایجاد کاربر
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|