feat(org): org creation wizard + app profile (v2 phase A1-2)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user