feat(org): org creation wizard + app profile (v2 phase A1-2)

This commit is contained in:
mjalmoudy94
2026-10-14 10:46:47 +03:30
parent 5095a29df9
commit 5fb85d6eed
13 changed files with 3485 additions and 71 deletions
+348 -2
View File
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import type { AuditEvent, Invite, Member, Role } from "@agy/contracts";
import type { AppProfile, AuditEvent, DeployTargetSummary, Invite, Member, Role } from "@agy/contracts";
import {
Badge,
Button,
@@ -19,9 +19,11 @@ import {
import { ApiError, api } from "@/lib/api";
import {
CAN_MANAGE_MEMBERS,
CAN_MANAGE_PROJECTS,
CAN_VIEW_AUDIT,
roleHasCapability,
} from "@/lib/permissions";
import { appProfileApi, setupApi } from "@/lib/api-org";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<Role, string> = {
@@ -62,7 +64,7 @@ const AUDIT_LABELS: Record<string, string> = {
"run.cancel": "لغو اجرا",
};
type Tab = "members" | "audit";
type Tab = "members" | "audit" | "app";
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
const INVITE_TTL_DAYS = 14;
@@ -206,6 +208,7 @@ export default function SettingsPage() {
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
const canManageProjects = roleHasCapability(activeOrganization.role, CAN_MANAGE_PROJECTS);
return (
<div className="anim-stagger flex flex-col gap-5">
@@ -218,6 +221,7 @@ export default function SettingsPage() {
{(
[
["members", "اعضا و دعوت"],
["app", "اپلیکیشن"],
["audit", "رویدادهای حسابرسی"],
] as const
).map(([value, label]) => (
@@ -372,6 +376,8 @@ export default function SettingsPage() {
</Card>
)}
</>
) : tab === "app" ? (
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
) : (
<Card>
<CardTitle>رویدادهای حسابرسی</CardTitle>
@@ -422,3 +428,343 @@ export default function SettingsPage() {
</div>
);
}
/**
* "اپلیکیشن" tab: edit the single app's profile, rotate git credentials, and
* edit or test the production deploy target. Owner/admin only — viewers see a
* read-only summary. (v2 A1-2 §2.8)
*/
function AppTab({ orgId, canEdit }: { orgId: string; canEdit: boolean }) {
const [profile, setProfile] = React.useState<AppProfile | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [form, setForm] = React.useState<Record<string, string>>({});
const [savingProfile, setSavingProfile] = React.useState(false);
// git credential rotation
const [gitAuth, setGitAuth] = React.useState("token");
const [gitUser, setGitUser] = React.useState("");
const [gitSecret, setGitSecret] = React.useState("");
const [gitSaved, setGitSaved] = React.useState<string | null>(null);
const [savingGit, setSavingGit] = React.useState(false);
// deploy target
const [dt, setDt] = React.useState({
kind: "ssh",
host: "",
port: "22",
username: "",
auth_type: "password",
secret: "",
remote_path: "/",
});
const [dtCheck, setDtCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
const [dtChecking, setDtChecking] = React.useState(false);
const [dtSaved, setDtSaved] = React.useState<DeployTargetSummary | null>(null);
const [savingDt, setSavingDt] = React.useState(false);
const load = React.useCallback(async () => {
if (!orgId) return;
setLoading(true);
setError(null);
try {
const p = await appProfileApi.get(orgId);
setProfile(p);
setForm({
app_type: p.app_type ?? "",
language: p.language ?? "",
framework: p.framework ?? "",
runtime_version: p.runtime_version ?? "",
package_manager: p.package_manager ?? "",
setup_command: p.setup_command ?? "",
build_command: p.build_command ?? "",
test_command: p.test_command ?? "",
start_command: p.start_command ?? "",
app_port: p.app_port != null ? String(p.app_port) : "",
healthcheck_path: p.healthcheck_path ?? "/",
external_repo_url: p.external_repo_url ?? "",
default_branch: p.default_branch ?? "main",
});
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت پروفایل ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
const setField = (key: string, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const saveProfile = async () => {
setSavingProfile(true);
setError(null);
try {
const values: Record<string, string | number | null> = {};
for (const k of [
"app_type",
"language",
"framework",
"runtime_version",
"package_manager",
"setup_command",
"build_command",
"test_command",
"start_command",
"healthcheck_path",
"external_repo_url",
"default_branch",
]) {
const v = form[k] ?? "";
values[k] = k === "app_port" ? (v ? Number(v) : null) : v === "" ? null : v;
}
const updated = await appProfileApi.update(orgId, values as never);
setProfile(updated);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ذخیره پروفایل ناموفق بود.");
} finally {
setSavingProfile(false);
}
};
const saveGit = async () => {
setSavingGit(true);
setGitSaved(null);
setError(null);
try {
const res = await appProfileApi.putGitCredentials(orgId, {
auth_type: gitAuth,
username: gitUser || null,
secret: gitSecret || null,
});
setGitSaved(res.has_secret ? "اعتبارنامه به‌روزرسانی شد." : "اعتبارنامه ثبت شد.");
setGitSecret("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "چرخش اعتبارنامه ناموفق بود.");
} finally {
setSavingGit(false);
}
};
const checkDeploy = async () => {
setDtChecking(true);
setDtCheck(null);
setError(null);
try {
const res = await setupApi.checkDeploy({
kind: dt.kind,
host: dt.host,
port: Number(dt.port) || 22,
username: dt.username,
auth_type: dt.auth_type,
secret: dt.secret || null,
remote_path: dt.remote_path || "/",
});
setDtCheck({ ok: res.ok, message: res.message });
} catch (err) {
setDtCheck({
ok: false,
message: err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.",
});
} finally {
setDtChecking(false);
}
};
const saveDeploy = async () => {
setSavingDt(true);
setError(null);
try {
const saved = await appProfileApi.putDeployTarget(orgId, {
kind: dt.kind,
host: dt.host.trim(),
port: Number(dt.port) || 22,
username: dt.username.trim(),
auth_type: dt.auth_type,
secret: dt.secret || null,
remote_path: dt.remote_path || "/",
});
setDtSaved(saved);
setDt((prev) => ({ ...prev, secret: "" }));
} catch (err) {
setError(err instanceof ApiError ? err.message : "ذخیره هدف انتشار ناموفق بود.");
} finally {
setSavingDt(false);
}
};
if (loading) {
return (
<Card>
<LoadingState label="در حال دریافت پروفایل اپلیکیشن…" />
</Card>
);
}
if (!profile) {
return (
<Card>
<EmptyState title="اپلیکیشنی یافت نشد" description="این سازمان هنوز اپلیکیشنی ندارد." />
</Card>
);
}
const readOnly = !canEdit;
return (
<div className="anim-stagger flex flex-col gap-5">
{error && <ErrorState message={error} onRetry={load} />}
{readOnly && (
<Card>
<CardHint>فقط مالک یا مدیر سازمان می‌تواند پروفایل اپلیکیشن را ویرایش کند.</CardHint>
</Card>
)}
{/* Profile */}
<Card>
<CardTitle>پروفایل اپلیکیشن</CardTitle>
<CardHint>
این‌ها همان تنظیماتی هستند که در جادوگر وارد کردید. منبع گیت فقط قابل چرخش
اعتبارنامه است و پس از ساخت تغییر نمی‌کند.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<Field label="نوع" value={form.app_type} disabled={readOnly} onChange={(e) => setField("app_type", e.target.value)} className="ltr-inline" />
<Field label="زبان" value={form.language} disabled={readOnly} onChange={(e) => setField("language", e.target.value)} className="ltr-inline" />
<Field label="چارچوب" value={form.framework} disabled={readOnly} onChange={(e) => setField("framework", e.target.value)} className="ltr-inline" />
<Field label="نسخه runtime" value={form.runtime_version} disabled={readOnly} onChange={(e) => setField("runtime_version", e.target.value)} className="ltr-inline" />
<Field label="مدیر بسته" value={form.package_manager} disabled={readOnly} onChange={(e) => setField("package_manager", e.target.value)} className="ltr-inline" />
<Field label="پورت" type="number" value={form.app_port} disabled={readOnly} onChange={(e) => setField("app_port", e.target.value)} className="ltr-inline" />
</div>
<Field label="دستور نصب" value={form.setup_command} disabled={readOnly} onChange={(e) => setField("setup_command", e.target.value)} className="ltr-inline" />
<Field label="دستور ساخت" value={form.build_command} disabled={readOnly} onChange={(e) => setField("build_command", e.target.value)} className="ltr-inline" />
<Field label="دستور تست" value={form.test_command} disabled={readOnly} onChange={(e) => setField("test_command", e.target.value)} className="ltr-inline" />
<Field label="دستور اجرا" value={form.start_command} disabled={readOnly} onChange={(e) => setField("start_command", e.target.value)} className="ltr-inline" />
<div className="grid grid-cols-2 gap-3">
<Field label="مسیر healthcheck" value={form.healthcheck_path} disabled={readOnly} onChange={(e) => setField("healthcheck_path", e.target.value)} className="ltr-inline" />
<Field label="شاخه پیش‌فرض" value={form.default_branch} disabled={readOnly} onChange={(e) => setField("default_branch", e.target.value)} className="ltr-inline" />
</div>
<Field label="آدرس مخزن خارجی" value={form.external_repo_url} disabled={readOnly} onChange={(e) => setField("external_repo_url", e.target.value)} className="ltr-inline" />
{canEdit && (
<Button onClick={saveProfile} loading={savingProfile}>
ذخیره تغییرات
</Button>
)}
</div>
</Card>
{/* Git credentials rotation */}
<Card>
<CardTitle>اعتبارنامهٔ گیت</CardTitle>
<CardHint>
منبع فعلی: {profile.git_source === "platform" ? "Git پلتفرم" : "مخزن خودم"}. تغییر
منبع پس از ساخت امکان‌پذیر نیست؛ فقط اعتبارنامه را بچرخانید.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
<Select
label="نوع احراز"
value={gitAuth}
disabled={readOnly}
onChange={(e) => setGitAuth(e.target.value)}
options={[
{ value: "token", label: "توکن" },
{ value: "ssh", label: "کلید SSH" },
]}
/>
<Field label="نام کاربری (اختیاری)" value={gitUser} disabled={readOnly} onChange={(e) => setGitUser(e.target.value)} className="ltr-inline" />
<Field
label={gitAuth === "ssh" ? "کلید خصوصی SSH" : "توکن / رمز"}
type="password"
value={gitSecret}
disabled={readOnly}
onChange={(e) => setGitSecret(e.target.value)}
hint="خالی بماند تا مقدار فعلی تغییر نکند."
className="ltr-inline"
/>
{gitSaved && <p className="text-xs text-success">{gitSaved}</p>}
{canEdit && (
<Button variant="secondary" onClick={saveGit} loading={savingGit}>
چرخش اعتبارنامه
</Button>
)}
</div>
</Card>
{/* Deploy target */}
<Card>
<CardTitle>هدف انتشار تولید</CardTitle>
<CardHint>
انتشار در تولید فقط با دکمهٔ خودتان انجام می‌شود. در اینجا فقط هدف را ثبت یا
به‌روزرسانی می‌کنید.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
<Select
label="نوع"
value={dt.kind}
disabled={readOnly}
onChange={(e) => setDt((p) => ({ ...p, kind: e.target.value }))}
options={[
{ value: "ssh", label: "SSH" },
{ value: "sftp", label: "SFTP" },
{ value: "ftp", label: "FTP" },
{ value: "ftps", label: "FTPS" },
]}
/>
<div className="grid grid-cols-3 gap-3">
<Field label="میزبان" value={dt.host} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, host: e.target.value }))} className="ltr-inline" />
<Field label="پورت" type="number" value={dt.port} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, port: e.target.value }))} className="ltr-inline" />
<Field label="نام کاربری" value={dt.username} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, username: e.target.value }))} className="ltr-inline" />
</div>
<Select
label="نوع احراز"
value={dt.auth_type}
disabled={readOnly}
onChange={(e) => setDt((p) => ({ ...p, auth_type: e.target.value }))}
options={[
{ value: "password", label: "رمز عبور" },
{ value: "ssh_key", label: "کلید SSH" },
]}
/>
<Field
label={dt.auth_type === "ssh_key" ? "کلید خصوصی SSH" : "رمز عبور"}
type="password"
value={dt.secret}
disabled={readOnly}
onChange={(e) => setDt((p) => ({ ...p, secret: e.target.value }))}
className="ltr-inline"
/>
<Field label="مسیر مقصد" value={dt.remote_path} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, remote_path: e.target.value }))} className="ltr-inline" />
{dtCheck && (
<p className={"text-xs " + (dtCheck.ok ? "text-success" : "text-danger")}>
{dtCheck.ok ? "اتصال برقرار است." : dtCheck.message}
</p>
)}
{dtSaved && (
<p className={"text-xs " + (dtSaved.last_test_ok === false ? "text-danger" : "text-success")}>
{dtSaved.last_test_ok === false
? `هدف ذخیره شد (آخرین تست ناموفق: ${dtSaved.last_test_message ?? "—"})`
: "هدف انتشار ذخیره شد."}
</p>
)}
{canEdit && (
<div className="flex gap-2">
<Button variant="secondary" onClick={checkDeploy} loading={dtChecking}>
تست اتصال
</Button>
<Button onClick={saveDeploy} loading={savingDt}>
ذخیره هدف
</Button>
</div>
)}
</div>
</Card>
</div>
);
}
+771 -67
View File
@@ -2,53 +2,343 @@
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 {
canCreateOrganization,
type CatalogDefaults,
type CatalogFramework,
type CatalogLanguage,
type CatalogPayload,
type WizardCompleteResult,
} from "@agy/contracts";
import {
Button,
Card,
CardHint,
CardTitle,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { setupApi } from "@/lib/api-org";
import { useSession } from "@/lib/session";
/** Every editable field of the wizard, kept as strings for uncontrolled inputs. */
interface WizardForm {
org_name: string;
slug: string;
app_name: string;
app_type: string;
language: string;
framework: string;
runtime_version: string;
package_manager: string;
setup_command: string;
build_command: string;
test_command: string;
start_command: string;
app_port: string;
healthcheck_path: string;
git_source: "platform" | "external";
external_repo_url: string;
external_git_auth_type: string;
external_git_username: string;
external_git_secret: string;
deploy_kind: string;
deploy_host: string;
deploy_port: string;
deploy_username: string;
deploy_auth_type: string;
deploy_secret: string;
deploy_remote_path: string;
}
const EMPTY_FORM: WizardForm = {
org_name: "",
slug: "",
app_name: "",
app_type: "",
language: "",
framework: "",
runtime_version: "",
package_manager: "",
setup_command: "",
build_command: "",
test_command: "",
start_command: "",
app_port: "",
healthcheck_path: "/",
git_source: "platform",
external_repo_url: "",
external_git_auth_type: "token",
external_git_username: "",
external_git_secret: "",
deploy_kind: "",
deploy_host: "",
deploy_port: "22",
deploy_username: "",
deploy_auth_type: "password",
deploy_secret: "",
deploy_remote_path: "/",
};
const STEP_TITLES = [
"سازمان",
"اپلیکیشن",
"مخزن گیت",
"تولید (اختیاری)",
"بازبینی",
];
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 120);
}
/**
* First-run flow: create an organization.
* First-run flow for an invited manager: a 5-step wizard that creates one
* organization with exactly one application, wires up git (platform or the
* user's own repo) and optionally a production deploy target.
*
* 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.
* The backend enforces 1 org = 1 app, so a manager who already has an org is
* bounced to the dashboard instead of being shown a dead-end form.
*/
export default function OnboardingPage() {
const router = useRouter();
const { ready, user, organizations, refresh } = useSession();
const [name, setName] = React.useState("");
const { ready, user, organizations, refresh, setActiveOrganization } = useSession();
const [catalog, setCatalog] = React.useState<CatalogPayload | null>(null);
const [step, setStep] = React.useState(1);
const [form, setForm] = React.useState<WizardForm>(EMPTY_FORM);
const [slugTouched, setSlugTouched] = React.useState(false);
const [loadingCatalog, setLoadingCatalog] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
// per-step transient status (git/deploy checks, creating)
const [gitCheck, setGitCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
const [gitChecking, setGitChecking] = React.useState(false);
const [deployCheck, setDeployCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
const [deployChecking, setDeployChecking] = React.useState(false);
const [result, setResult] = React.useState<WizardCompleteResult | null>(null);
React.useEffect(() => {
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();
// Load catalog + any resumable draft.
React.useEffect(() => {
if (!ready || !user) return;
let cancelled = false;
(async () => {
try {
const [cat, draft] = await Promise.all([setupApi.getCatalog(), setupApi.getDraft()]);
if (cancelled) return;
setCatalog(cat);
if (draft && draft.data && Object.keys(draft.data).length > 0) {
setForm((prev) => ({ ...prev, ...(draft.data as Partial<WizardForm>) }));
setStep(Math.min(Math.max(draft.step, 1), 5));
if ((draft.data as Partial<WizardForm>).slug) setSlugTouched(true);
}
} catch (err) {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "بارگذاری راه‌انداز ناموفق بود.");
} finally {
if (!cancelled) setLoadingCatalog(false);
}
})();
return () => {
cancelled = true;
};
}, [ready, user]);
const update = <K extends keyof WizardForm>(key: K, value: WizardForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const selectedLanguage = React.useMemo<CatalogLanguage | null>(
() => catalog?.languages.find((l) => l.key === form.language) ?? null,
[catalog, form.language],
);
const selectedFramework = React.useMemo<CatalogFramework | null>(() => {
if (!selectedLanguage) return null;
return selectedLanguage.frameworks.find((f) => f.key === form.framework) ?? null;
}, [selectedLanguage, form.framework]);
const applyFrameworkDefaults = (fw: CatalogFramework) => {
const d: CatalogDefaults = fw.defaults;
setForm((prev) => ({
...prev,
framework: fw.key,
app_type: fw.app_type,
runtime_version: d.runtime_version,
package_manager: d.package_manager,
setup_command: d.setup_command ?? "",
build_command: d.build_command ?? "",
test_command: d.test_command ?? "",
start_command: d.start_command,
app_port: String(d.app_port),
healthcheck_path: d.healthcheck_path,
}));
};
const persistDraft = React.useCallback(
async (nextStep: number, data: WizardForm) => {
setSaving(true);
try {
// Secrets are held only in the final submit request — never persisted
// to the resumable draft. Strip them so a resumed draft never leaks a
// token or key through logs, the DB row or a shared machine.
const { external_git_secret, deploy_secret, ...safe } = data;
void external_git_secret;
void deploy_secret;
await setupApi.saveDraft(nextStep, safe as unknown as Record<string, unknown>);
} catch {
// Draft auto-save is best-effort; never block navigation on it.
} finally {
setSaving(false);
}
},
[],
);
const goNext = () => {
const next = Math.min(step + 1, 5);
setStep(next);
void persistDraft(next, form);
};
const goBack = () => {
const prev = Math.max(step - 1, 1);
setStep(prev);
void persistDraft(prev, form);
};
const onOrgNameChange = (value: string) => {
setForm((prev) => ({
...prev,
org_name: value,
slug: slugTouched ? prev.slug : slugify(value),
}));
};
const runGitCheck = async () => {
setGitChecking(true);
setGitCheck(null);
setError(null);
setSubmitting(true);
try {
await api.createOrganization(name.trim());
await refresh();
router.replace("/dashboard");
const res = await setupApi.checkGit({
url: form.external_repo_url,
auth_type: form.external_git_auth_type,
username: form.external_git_username || null,
secret: form.external_git_secret || null,
});
setGitCheck({ ok: res.ok, message: res.message });
if (res.default_branch) update("healthcheck_path", form.healthcheck_path || "/");
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود.");
setGitCheck({
ok: false,
message: err instanceof ApiError ? err.message : "بررسی اتصال ناموفق بود.",
});
} finally {
setSubmitting(false);
setGitChecking(false);
}
};
if (!ready) {
const runDeployCheck = async () => {
if (!form.deploy_kind) return;
setDeployChecking(true);
setDeployCheck(null);
setError(null);
try {
const res = await setupApi.checkDeploy({
kind: form.deploy_kind,
host: form.deploy_host,
port: Number(form.deploy_port) || 22,
username: form.deploy_username,
auth_type: form.deploy_auth_type,
secret: form.deploy_secret || null,
remote_path: form.deploy_remote_path || "/",
});
setDeployCheck({ ok: res.ok, message: res.message });
} catch (err) {
setDeployCheck({
ok: false,
message: err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.",
});
} finally {
setDeployChecking(false);
}
};
const buildPayload = (): import("@agy/contracts").WizardCompleteBody => {
const body: import("@agy/contracts").WizardCompleteBody = {
org_name: form.org_name.trim(),
slug: form.slug.trim(),
app_name: form.app_name.trim(),
app_type: form.app_type,
language: form.language,
framework: form.framework,
runtime_version: form.runtime_version || null,
package_manager: form.package_manager || null,
setup_command: form.setup_command || null,
build_command: form.build_command || null,
test_command: form.test_command || null,
start_command: form.start_command || null,
app_port: form.app_port ? Number(form.app_port) : null,
healthcheck_path: form.healthcheck_path || "/",
git_source: form.git_source,
};
if (form.git_source === "external") {
body.external_repo_url = form.external_repo_url.trim() || null;
body.external_git_auth_type = form.external_git_auth_type;
body.external_git_username = form.external_git_username || null;
body.external_git_secret = form.external_git_secret || null;
}
if (form.deploy_kind) {
body.deploy_target_kind = form.deploy_kind;
body.deploy_target_host = form.deploy_host.trim();
body.deploy_target_port = Number(form.deploy_port) || 22;
body.deploy_target_username = form.deploy_username.trim();
body.deploy_target_auth_type = form.deploy_auth_type;
body.deploy_target_secret = form.deploy_secret || null;
body.deploy_target_remote_path = form.deploy_remote_path || "/";
}
return body;
};
const onSubmit = async () => {
setError(null);
setSaving(true);
try {
const res = await setupApi.complete(buildPayload());
setResult(res);
await refresh();
if (res.organization?.id) setActiveOrganization(res.organization.id);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود.");
} finally {
setSaving(false);
}
};
// ----- guards / loading ---------------------------------------------------
if (!ready || loadingCatalog) {
return (
<div className="flex min-h-screen items-center justify-center p-8">
<LoadingState />
@@ -62,63 +352,477 @@ export default function OnboardingPage() {
<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>
);
}
if (result) {
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-md text-center">
<CardTitle>سازمان ساخته شد</CardTitle>
<CardHint>
«{result.organization.name}» و اپلیکیشن «{result.project.name}» آماده شدند.
حالا می‌توانید تیم هوش‌مصنوعی خود را راه‌بیندازید.
</CardHint>
<div className="mt-5">
<Button className="w-full" onClick={() => router.replace("/manager")}>
برو به مدیر AI
</Button>
</div>
</Card>
</div>
);
}
// ----- step body ----------------------------------------------------------
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>
<Card className="anim-rise w-full max-w-xl">
<Stepper step={step} />
{organizations.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
<p className="text-xs text-slate-400">سازمان‌های فعلی شما:</p>
{organizations.map((org) => (
<button
key={org.id}
type="button"
onClick={() => router.replace("/dashboard")}
className="transition-colors rounded-lg border border-surface-border px-3 py-2 text-right text-xs text-slate-200 hover:bg-surface-overlay"
>
{org.name}
</button>
))}
<div className="my-2 border-t border-surface-border" />
</div>
)}
{error && <div className="mt-4"><ErrorState title="خطا" message={error} /></div>}
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-4">
<Field
label="نام سازمان"
required
minLength={2}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً شرکت نمونه"
/>
{error && <ErrorState title="ساخت سازمان ناموفق" message={error} />}
<Button type="submit" loading={submitting}>
ساخت سازمان
<div className="mt-4">
{step === 1 && (
<OrgStep form={form} onOrgName={onOrgNameChange} onSlug={(v) => { setSlugTouched(true); update("slug", v); }} />
)}
{step === 2 && catalog && (
<AppStep
form={form}
catalog={catalog}
update={update}
onFramework={applyFrameworkDefaults}
/>
)}
{step === 3 && (
<GitStep
form={form}
update={update}
gitCheck={gitCheck}
gitChecking={gitChecking}
onCheck={runGitCheck}
/>
)}
{step === 4 && (
<ProductionStep
form={form}
update={update}
deployCheck={deployCheck}
deployChecking={deployChecking}
onCheck={runDeployCheck}
/>
)}
{step === 5 && <ReviewStep form={form} catalog={catalog} language={selectedLanguage} framework={selectedFramework} />}
</div>
<div className="mt-6 flex items-center justify-between gap-3">
<Button variant="secondary" onClick={goBack} disabled={step === 1 || saving}>
قبلی
</Button>
</form>
{step < 5 ? (
<Button onClick={goNext} disabled={saving}>
بعدی
</Button>
) : (
<Button onClick={onSubmit} loading={saving} disabled={saving}>
ساخت سازمان
</Button>
)}
</div>
</Card>
</div>
);
}
// ---------------------------------------------------------------------------
// Stepper
// ---------------------------------------------------------------------------
function Stepper({ step }: { step: number }) {
return (
<div className="flex flex-col gap-3">
<CardTitle>راه‌اندازی سازمان</CardTitle>
<ol className="flex items-center gap-1">
{STEP_TITLES.map((title, idx) => {
const n = idx + 1;
const state = n === step ? "current" : n < step ? "done" : "todo";
return (
<li key={title} className="flex flex-1 items-center gap-1">
<span
className={
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs " +
(state === "current"
? "bg-accent text-white"
: state === "done"
? "bg-accent/30 text-accent"
: "bg-surface-overlay text-slate-500")
}
>
{n}
</span>
<span
className={
"hidden text-xs sm:inline " +
(state === "todo" ? "text-slate-500" : "text-slate-200")
}
>
{title}
</span>
{n < STEP_TITLES.length && <span className="h-px flex-1 bg-surface-border" />}
</li>
);
})}
</ol>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 1 — Organization
// ---------------------------------------------------------------------------
function OrgStep({
form,
onOrgName,
onSlug,
}: {
form: WizardForm;
onOrgName: (v: string) => void;
onSlug: (v: string) => void;
}) {
return (
<div className="flex flex-col gap-4">
<CardHint>سازمان ظرف اصلی محصول است. هر سازمان دقیقاً یک اپلیکیشن دارد.</CardHint>
<Field
label="نام سازمان"
required
minLength={2}
value={form.org_name}
onChange={(e) => onOrgName(e.target.value)}
placeholder="مثلاً شرکت نمونه"
/>
<Field
label="نامک (slug)"
required
value={form.slug}
onChange={(e) => onSlug(e.target.value)}
hint="فقط حروف کوچک انگلیسی، عدد و خط تیره. در نشانی دمو استفاده می‌شود."
className="ltr-inline"
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 2 — Application
// ---------------------------------------------------------------------------
function AppStep({
form,
catalog,
update,
onFramework,
}: {
form: WizardForm;
catalog: CatalogPayload;
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
onFramework: (fw: CatalogFramework) => void;
}) {
const [showAdvanced, setShowAdvanced] = React.useState(false);
const language = catalog.languages.find((l) => l.key === form.language) ?? null;
return (
<div className="flex flex-col gap-4">
<CardHint>نوع، زبان و چارچوب اپلیکیشن را انتخاب کنید.</CardHint>
<Field
label="نام اپلیکیشن"
required
value={form.app_name}
onChange={(e) => update("app_name", e.target.value)}
placeholder="مثلاً پنل مشتریان"
/>
<Select
label="نوع اپلیکیشن"
value={form.app_type}
onChange={(e) => update("app_type", e.target.value)}
options={[
{ value: "", label: "انتخاب کنید…" },
...catalog.app_types.map((t) => ({
value: t,
label: catalog.app_type_labels[t] ?? t,
})),
]}
/>
<Select
label="زبان"
value={form.language}
onChange={(e) => {
update("language", e.target.value);
update("framework", "");
}}
options={[
{ value: "", label: "انتخاب کنید…" },
...catalog.languages.map((l) => ({ value: l.key, label: l.display })),
]}
/>
<Select
label="چارچوب"
value={form.framework}
disabled={!language}
onChange={(e) => {
const fw = language?.frameworks.find((f) => f.key === e.target.value);
if (fw) onFramework(fw);
}}
options={[
{ value: "", label: language ? "انتخاب کنید…" : "اول زبان را انتخاب کنید" },
...(language?.frameworks.map((f) => ({ value: f.key, label: f.display })) ?? []),
]}
/>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="self-start text-xs text-accent hover:underline"
>
{showAdvanced ? "بستن تنظیمات پیشرفته" : "تنظیمات پیشرفته (دستورات و پورت)"}
</button>
{showAdvanced && (
<div className="flex flex-col gap-3 rounded-lg border border-surface-border p-3">
<div className="grid grid-cols-2 gap-3">
<Field label="نسخه runtime" value={form.runtime_version} onChange={(e) => update("runtime_version", e.target.value)} className="ltr-inline" />
<Field label="مدیر بسته" value={form.package_manager} onChange={(e) => update("package_manager", e.target.value)} className="ltr-inline" />
</div>
<Field label="دستور نصب" value={form.setup_command} onChange={(e) => update("setup_command", e.target.value)} className="ltr-inline" />
<Field label="دستور ساخت" value={form.build_command} onChange={(e) => update("build_command", e.target.value)} className="ltr-inline" />
<Field label="دستور تست" value={form.test_command} onChange={(e) => update("test_command", e.target.value)} className="ltr-inline" />
<Field label="دستور اجرا" value={form.start_command} onChange={(e) => update("start_command", e.target.value)} className="ltr-inline" />
<div className="grid grid-cols-2 gap-3">
<Field label="پورت اپلیکیشن" type="number" value={form.app_port} onChange={(e) => update("app_port", e.target.value)} className="ltr-inline" />
<Field label="مسیر healthcheck" value={form.healthcheck_path} onChange={(e) => update("healthcheck_path", e.target.value)} className="ltr-inline" />
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 3 — Git
// ---------------------------------------------------------------------------
function GitStep({
form,
update,
gitCheck,
gitChecking,
onCheck,
}: {
form: WizardForm;
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
gitCheck: { ok: boolean; message?: string } | null;
gitChecking: boolean;
onCheck: () => void;
}) {
return (
<div className="flex flex-col gap-4">
<CardHint>مخزن گیت اپلیکیشن را انتخاب کنید. گیت را همیشه پلتفرم مدیریت می‌کند.</CardHint>
<Select
label="منبع گیت"
value={form.git_source}
onChange={(e) => update("git_source", e.target.value as WizardForm["git_source"])}
options={[
{ value: "platform", label: "Git پلتفرم (پیشنهادی)" },
{ value: "external", label: "Git خودم" },
]}
/>
{form.git_source === "platform" ? (
<p className="rounded-lg border border-surface-border bg-surface-overlay p-3 text-xs text-slate-300">
پلتفرم یک مخزن خصوصی برایتان می‌سازد و کلیدهای دسترسی را امن نگه می‌دارد. نیازی به
تنظیم چیزی نیست.
</p>
) : (
<div className="flex flex-col gap-3">
<Field
label="آدرس مخزن (HTTPS یا SSH)"
required
value={form.external_repo_url}
onChange={(e) => update("external_repo_url", e.target.value)}
placeholder="https://github.com/owner/repo.git"
className="ltr-inline"
/>
<Select
label="نوع احراز"
value={form.external_git_auth_type}
onChange={(e) => update("external_git_auth_type", e.target.value)}
options={[
{ value: "token", label: "توکن (access token)" },
{ value: "ssh", label: "کلید SSH" },
]}
/>
<Field
label="نام کاربری (اختیاری)"
value={form.external_git_username}
onChange={(e) => update("external_git_username", e.target.value)}
className="ltr-inline"
/>
<Field
label={form.external_git_auth_type === "ssh" ? "کلید خصوصی SSH" : "توکن / رمز"}
type="password"
required
value={form.external_git_secret}
onChange={(e) => update("external_git_secret", e.target.value)}
hint="رمز فقط در درخواست نهایی فرستاده می‌شود و هرگز در پیش‌نویس ذخیره نمی‌گردد."
className="ltr-inline"
/>
<div>
<Button variant="secondary" onClick={onCheck} loading={gitChecking}>
بررسی اتصال
</Button>
</div>
{gitCheck && (
<p className={"text-xs " + (gitCheck.ok ? "text-success" : "text-danger")}>
{gitCheck.ok ? "اتصال برقرار است." : gitCheck.message}
</p>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 4 — Production (optional)
// ---------------------------------------------------------------------------
function ProductionStep({
form,
update,
deployCheck,
deployChecking,
onCheck,
}: {
form: WizardForm;
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
deployCheck: { ok: boolean; message?: string } | null;
deployChecking: boolean;
onCheck: () => void;
}) {
const skip = !form.deploy_kind;
return (
<div className="flex flex-col gap-4">
<CardHint>
انتشار در تولید فقط وقتی انجام می‌شود که خودتان دکمه را بزنید. اینجا فقط تنظیمات را
ثبت می‌کنید؛ هیچ کدی بدون کلیک شما به تولید نمی‌رود.
</CardHint>
<Select
label="نوع انتشار (اختیاری)"
value={form.deploy_kind}
onChange={(e) => update("deploy_kind", e.target.value)}
options={[
{ value: "", label: "رد کردن (فعلاً نمی‌خواهم)" },
{ value: "ssh", label: "SSH" },
{ value: "sftp", label: "SFTP" },
{ value: "ftp", label: "FTP" },
{ value: "ftps", label: "FTPS" },
]}
/>
{!skip && (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-3">
<Field label="میزبان" required value={form.deploy_host} onChange={(e) => update("deploy_host", e.target.value)} className="ltr-inline" />
<Field label="پورت" type="number" value={form.deploy_port} onChange={(e) => update("deploy_port", e.target.value)} className="ltr-inline" />
<Field label="نام کاربری" required value={form.deploy_username} onChange={(e) => update("deploy_username", e.target.value)} className="ltr-inline" />
</div>
<Select
label="نوع احراز"
value={form.deploy_auth_type}
onChange={(e) => update("deploy_auth_type", e.target.value)}
options={[
{ value: "password", label: "رمز عبور" },
{ value: "ssh_key", label: "کلید SSH" },
]}
/>
<Field
label={form.deploy_auth_type === "ssh_key" ? "کلید خصوصی SSH" : "رمز عبور"}
type="password"
required
value={form.deploy_secret}
onChange={(e) => update("deploy_secret", e.target.value)}
className="ltr-inline"
/>
<Field label="مسیر مقصد" value={form.deploy_remote_path} onChange={(e) => update("deploy_remote_path", e.target.value)} className="ltr-inline" />
<div>
<Button variant="secondary" onClick={onCheck} loading={deployChecking}>
تست اتصال
</Button>
</div>
{deployCheck && (
<p className={"text-xs " + (deployCheck.ok ? "text-success" : "text-danger")}>
{deployCheck.ok ? "اتصال برقرار است." : deployCheck.message}
</p>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 5 — Review
// ---------------------------------------------------------------------------
function ReviewStep({
form,
catalog,
language,
framework,
}: {
form: WizardForm;
catalog: CatalogPayload | null;
language: CatalogLanguage | null;
framework: CatalogFramework | null;
}) {
const typeLabel = form.app_type ? (catalog?.app_type_labels[form.app_type] ?? form.app_type) : "—";
const rows: Array<[string, string]> = [
["سازمان", form.org_name || "—"],
["نامک", form.slug || "—"],
["اپلیکیشن", form.app_name || "—"],
["نوع", typeLabel],
["زبان", language?.display ?? "—"],
["چارچوب", framework?.display ?? "—"],
["گیت", form.git_source === "platform" ? "Git پلتفرم" : "مخزن خودم"],
];
if (form.git_source === "external") {
rows.push(["آدرس مخزن", form.external_repo_url || "—"]);
rows.push(["رمز گیت", form.external_git_secret ? "•••••••• (ثبت می‌شود)" : "—"]);
}
rows.push(["تولید", form.deploy_kind ? form.deploy_kind.toUpperCase() : "رد شده"]);
if (form.deploy_kind) {
rows.push(["میزبان تولید", form.deploy_host || "—"]);
rows.push(["رمز تولید", form.deploy_secret ? "•••••••• (ثبت می‌شود)" : "—"]);
}
return (
<div className="flex flex-col gap-3">
<CardHint>همه‌چیز درست است؟ با زدن «ساخت سازمان» ثبت نهایی انجام می‌شود.</CardHint>
<dl className="divide-y divide-surface-border rounded-lg border border-surface-border">
{rows.map(([k, v]) => (
<div key={k} className="flex items-center justify-between gap-3 px-3 py-2">
<dt className="text-xs text-slate-400">{k}</dt>
<dd className="ltr-inline max-w-[60%] truncate text-xs text-slate-100">{v}</dd>
</div>
))}
</dl>
</div>
);
}
+76
View File
@@ -5,8 +5,19 @@
*/
import type {
AppProfile,
AppProfileUpdate,
CatalogPayload,
CheckDeployBody,
CheckGitBody,
DeployTargetPutBody,
DeployTargetSummary,
GitCredentialsPutBody,
IssuedPlatformInvite,
PlatformInvite,
SetupDraft,
WizardCompleteBody,
WizardCompleteResult,
} from "@agy/contracts";
import { apiRequest } from "./api";
@@ -68,6 +79,71 @@ export const orgApi = {
};
/**
* The new-organization wizard lives outside any org (the org does not exist
* yet), so its endpoints are under `/setup/*` and take no org id. The wizard
* stores a resumable draft server-side; secrets are only ever sent in the
* final `complete` request and are never written to the draft.
*/
export const setupApi = {
/** Catalog of app types, languages and framework defaults (v2 §2.1). */
getCatalog: () => apiRequest<CatalogPayload>("/api/setup/catalog"),
/** Resume an in-progress draft (step + collected fields, no secrets). */
getDraft: () => apiRequest<SetupDraft>("/api/setup/draft"),
/** Persist the current step + fields. Returns nothing (204). */
saveDraft: (step: number, data: Record<string, unknown>) =>
apiRequest<void>("/api/setup/draft", { method: "PUT", body: { step, data } }),
/** Probe an external git URL (format + DNS/SSRF check runs server-side). */
checkGit: (body: CheckGitBody) =>
apiRequest<{ ok: boolean; default_branch?: string; branches?: string[]; message?: string }>(
"/api/setup/check-git",
{ method: "POST", body },
),
/** Probe a production deploy target connection (TOFU fingerprint on success). */
checkDeploy: (body: CheckDeployBody) =>
apiRequest<{ ok: boolean; message?: string; fingerprint?: string }>(
"/api/setup/check-deploy",
{ method: "POST", body },
),
/** Create the org + single app + optional git/deploy in one transaction. */
complete: (body: WizardCompleteBody) =>
apiRequest<WizardCompleteResult>("/api/setup/complete", { method: "POST", body }),
};
/**
* Org-scoped application profile (the single app of the org, v2 D1/D2). Owner
* and admin may edit; secrets are write-only and come back only as `has_secret`.
*/
export const appProfileApi = {
get: (orgId: string) =>
apiRequest<AppProfile>(`/api/organizations/${orgId}/app/profile`),
update: (orgId: string, values: AppProfileUpdate) =>
apiRequest<AppProfile>(`/api/organizations/${orgId}/app/profile`, {
method: "PATCH",
body: values,
}),
putDeployTarget: (orgId: string, body: DeployTargetPutBody) =>
apiRequest<DeployTargetSummary>(`/api/organizations/${orgId}/app/deploy-target`, {
method: "PUT",
body,
}),
putGitCredentials: (orgId: string, body: GitCredentialsPutBody) =>
apiRequest<{ git_source: string; has_secret: boolean }>(
`/api/organizations/${orgId}/app/git`,
{ method: "PUT", body },
),
};
/**
* 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.
+170
View File
@@ -0,0 +1,170 @@
/**
* §v2-A1.2 — the 5-step organization-creation wizard (frontend §2.7).
*
* The wizard is the only path that creates an org + single app, so the tests
* focus on what would silently corrupt the product: the catalog loading, step
* navigation, and — critically — that a typed git/deploy secret is never
* written into the resumable draft payload (only the final submit carries it).
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
session: {
ready: true,
user: { id: "u1", email: "m@e.com", platform_role: "manager" } as Record<string, unknown>,
organizations: [] as Array<Record<string, unknown>>,
activeOrganization: null as Record<string, unknown> | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
setupApi: {
getCatalog: vi.fn(),
getDraft: vi.fn(),
saveDraft: vi.fn((_step: number, _data: Record<string, unknown>) => Promise.resolve()),
checkGit: vi.fn(async () => ({ ok: true })),
checkDeploy: vi.fn(async () => ({ ok: true })),
complete: vi.fn(async () => ({
organization: { id: "org-1", name: "Acme", slug: "acme" },
project: { id: "p-1", name: "app", slug: "app" },
deploy_target_id: null,
})),
},
}));
vi.mock("next/navigation", () => ({
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 {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-org", () => ({ setupApi: mocks.setupApi }));
const replace = vi.fn();
Object.assign(mocks, { replace });
import OnboardingPage from "@/app/onboarding/page";
const CATALOG = {
app_types: ["web_app"],
app_type_labels: { web_app: "وب‌اپ" },
git_sources: ["platform", "external"],
languages: [
{
key: "node",
display: "Node.js",
frameworks: [
{
key: "next",
display: "Next.js",
app_type: "web_app",
defaults: {
runtime_version: "20",
package_manager: "npm",
setup_command: "npm ci",
build_command: "npm run build",
test_command: "npm test",
start_command: "npm start",
app_port: 3000,
healthcheck_path: "/",
docker_image: "node:20",
},
},
],
},
],
};
beforeEach(() => {
vi.clearAllMocks();
mocks.session.ready = true;
mocks.session.user = { id: "u1", email: "m@e.com", platform_role: "manager" };
mocks.session.organizations = [];
mocks.session.activeOrganization = null;
mocks.setupApi.getCatalog.mockResolvedValue(CATALOG);
mocks.setupApi.getDraft.mockResolvedValue({ step: 1, data: {} });
});
describe("the organization wizard", () => {
it("loads the catalog and opens on step 1 (سازمان)", async () => {
render(<OnboardingPage />);
await waitFor(() => expect(screen.getByText("نام سازمان")).toBeInTheDocument());
expect(mocks.setupApi.getCatalog).toHaveBeenCalledTimes(1);
});
it("auto-suggests a slug from the org name", async () => {
render(<OnboardingPage />);
const name = await screen.findByLabelText("نام سازمان");
fireEvent.change(name, { target: { value: "Acme Corp" } });
const slug = screen.getByLabelText("نامک (slug)") as HTMLInputElement;
expect(slug.value).toBe("acme-corp");
});
it("moves to step 2 (اپلیکیشن) on next", async () => {
render(<OnboardingPage />);
const name = await screen.findByLabelText("نام سازمان");
fireEvent.change(name, { target: { value: "Acme" } });
fireEvent.click(screen.getByText("بعدی"));
await waitFor(() => expect(screen.getByText("نوع اپلیکیشن")).toBeInTheDocument());
});
it("never writes the git secret into the draft payload", async () => {
// Resume at step 3 (git) with an external repo but no secret saved yet.
mocks.setupApi.getDraft.mockResolvedValue({
step: 3,
data: { git_source: "external", external_repo_url: "https://example.com/x.git" },
});
render(<OnboardingPage />);
const secret = await screen.findByLabelText("توکن / رمز");
fireEvent.change(secret, { target: { value: "supersecret-token-123" } });
// Advance to the production step — this triggers a draft auto-save.
fireEvent.click(screen.getByText("بعدی"));
await waitFor(() =>
expect(mocks.setupApi.saveDraft).toHaveBeenCalled(),
);
const lastCall = mocks.setupApi.saveDraft.mock.calls.at(-1)!;
const payload = lastCall[1] as Record<string, unknown>;
// The draft carries the non-secret repo url...
expect(payload).toMatchObject({ external_repo_url: "https://example.com/x.git" });
// ...but never the secret itself.
expect(payload).not.toHaveProperty("external_git_secret");
expect(JSON.stringify(payload)).not.toContain("supersecret-token-123");
});
it("bounces a manager who already owns an organization to the dashboard", async () => {
mocks.session.organizations = [{ id: "org-x", name: "X", slug: "x" }];
render(<OnboardingPage />);
await waitFor(() => expect(replace).toHaveBeenCalledWith("/dashboard"));
});
it("shows the waiting screen for a plain user who cannot create orgs", async () => {
mocks.session.user = { id: "u2", email: "p@e.com", platform_role: "user" };
render(<OnboardingPage />);
await waitFor(() => expect(screen.getByText("در انتظار دعوت")).toBeInTheDocument());
});
});