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
@@ -0,0 +1,43 @@
"""org wizard: draft table for resume-safe setup flow
Revision ID: d5e6f7a8b9c0
Revises: a1b2c3d4e5f1
Create Date: 2026-10-14 06:00:00.000000
Owned by Agent 1 (PLAN-v2 §6). Holds the non-secret half of the wizard state
so a refresh never loses the user's progress.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d5e6f7a8b9c0"
down_revision: str | None = "a1b2c3d4e5f1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"org_setup_drafts",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"user_id",
sa.String(length=32),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("step", sa.Integer(), nullable=False, server_default="1"),
sa.Column("data_json", sa.Text(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_org_setup_drafts_user_id", "org_setup_drafts", ["user_id"])
def downgrade() -> None:
op.drop_table("org_setup_drafts")
@@ -0,0 +1,596 @@
"""Organization creation wizard — from zero to a running app in one transaction.
The wizard is the *only* path that creates a project. It guarantees the 1-org-1-app
rule by checking before insertion and by relying on the DB unique constraint.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from urllib.parse import urlsplit
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks
from app.application.ports.deploy_target import DeployCheck, DeployTargetSpec, DeployTargetTester
from app.application.ports.git_provider import GitProviderPort
from app.application.ports.repo_workspace import RemoteCheck, RemoteCredentials, RepoWorkspacePort
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
from app.core.crypto import encrypt
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
from app.domain.base import ensure_unique_slug, new_id, slugify, utcnow
from app.domain.identity.models import (
Membership,
MembershipStatus,
Organization,
Role,
User,
)
from app.domain.platform.rules import is_blocked_address
from app.domain.projects.catalog import (
ProfileDefaults,
defaults_for,
)
from app.domain.projects.models import (
ProjectExternalGit,
ProjectGitCredential,
ProjectGitRepo,
)
from app.infrastructure.db.models import CreditWalletRow, ProjectRow
from app.infrastructure.db.models_org import OrgSetupDraftRow
from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS
from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository
from app.infrastructure.repositories.projects import (
DeployTargetRepository,
ProjectExternalGitRepository,
ProjectGitCredentialRepository,
ProjectGitRepoRepository,
ProjectRepository,
)
logger = logging.getLogger("agy.org.wizard")
#: A minimal allowed scheme set for user-supplied git remotes.
_GIT_SCHEMES = {"https", "ssh"}
@dataclass(slots=True, kw_only=True)
class WizardPayload:
"""Validated data the wizard submits on ``complete``."""
org_name: str
slug: str
app_name: str
app_type: str
language: str
framework: str
# Optional overrides on top of catalog defaults
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
external_git_auth_type: str = "token"
external_git_username: str | None = None
external_git_secret: str | None = None
# Optional deploy target
deploy_target_kind: str | None = None
deploy_target_host: str | None = None
deploy_target_port: int = 22
deploy_target_username: str | None = None
deploy_target_auth_type: str = "password"
deploy_target_secret: str | None = None
deploy_target_remote_path: str = "/"
class OrgSetupService:
"""Backend for the org-creation wizard and app-profile management."""
def __init__(
self,
session: AsyncSession,
*,
repo_workspace: RepoWorkspacePort | None = None,
deploy_tester: DeployTargetTester | None = None,
git_provider: GitProviderPort | None = None,
workforce_bootstrap: WorkforceBootstrapPort | None = None,
delivery_hooks: DeliveryHooks | None = None,
) -> None:
self.session = session
self.orgs = OrganizationRepository(session)
self.memberships = MembershipRepository(session)
self.projects = ProjectRepository(session)
self.external_git = ProjectExternalGitRepository(session)
self.git_repos = ProjectGitRepoRepository(session)
self.git_credentials = ProjectGitCredentialRepository(session)
self.deploys = DeployTargetRepository(session)
self.repo_workspace = repo_workspace
self.deploy_tester = deploy_tester
self.git_provider = git_provider
self.workforce_bootstrap = workforce_bootstrap
self.delivery_hooks = delivery_hooks
# ------------------------------------------------------------------
# Catalog
# ------------------------------------------------------------------
@staticmethod
def catalog() -> dict:
from app.domain.projects.catalog import catalog_payload
return catalog_payload()
# ------------------------------------------------------------------
# Draft
# ------------------------------------------------------------------
async def get_draft(self, user_id: str) -> dict:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
return {"step": 1, "data": {}}
return {"step": row.step, "data": json.loads(row.data_json)}
async def save_draft(self, user_id: str, step: int, data: dict) -> None:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
row = OrgSetupDraftRow(
user_id=user_id, step=step, data_json=json.dumps(data)
)
self.session.add(row)
else:
row.step = step
row.data_json = json.dumps(data)
await self.session.flush()
# ------------------------------------------------------------------
# Git remote validation
# ------------------------------------------------------------------
async def check_git(
self,
*,
url: str,
auth_type: str = "none",
username: str | None = None,
secret: str | None = None,
) -> RemoteCheck:
_validate_git_url(url)
if self.repo_workspace is None:
raise ValidationError("git workspace در دسترس نیست.")
return await self.repo_workspace.test_remote(
url=url,
credentials=RemoteCredentials(
auth_type=auth_type, username=username, secret=secret
),
)
# ------------------------------------------------------------------
# Deploy-target validation
# ------------------------------------------------------------------
async def check_deploy(self, *, target: DeployTargetSpec) -> DeployCheck:
if self.deploy_tester is None:
raise ValidationError("تستر مقصد دیپلوی در دسترس نیست.")
return await self.deploy_tester.test_connection(target=target)
# ------------------------------------------------------------------
# Wizard completion
# ------------------------------------------------------------------
async def complete(self, user: User, payload: WizardPayload) -> dict:
if not user.can_create_organization:
raise PermissionDeniedError(
"ساخت سازمان فقط برای مالک سامانه و مدیران امکان‌پذیر است."
)
# -- slug uniqueness -------------------------------------------------
base_slug = slugify(payload.slug or payload.org_name)
slug = ensure_unique_slug(base_slug, await self.orgs.list_all_slugs())
# -- defaults from catalog -------------------------------------------
catalog_defaults = defaults_for(
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
)
# -- create org + owner membership + wallet --------------------------
org = await self.orgs.create(
Organization(name=payload.org_name.strip(), slug=slug, owner_user_id=user.id)
)
await self.memberships.add(
Membership(
organization_id=org.id,
user_id=user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
# -- git setup -------------------------------------------------------
if payload.git_source == "platform":
await self._setup_platform_git(project_row.id, slug)
elif payload.git_source == "external":
await self._setup_external_git(
project_row.id,
url=payload.external_repo_url or "",
auth_type=payload.external_git_auth_type,
username=payload.external_git_username,
secret=payload.external_git_secret,
)
else:
raise ValidationError("منبع git نامعتبر است.")
# -- optional deploy target ------------------------------------------
deploy_target_id: str | None = None
if payload.deploy_target_kind:
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
await self.session.flush()
# -- commit so hooks see a consistent world --------------------------
await self.session.commit()
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return {
"organization": {
"id": org.id,
"name": org.name,
"slug": org.slug,
},
"project": {
"id": project_row.id,
"name": project_row.name,
"slug": project_row.slug,
},
"deploy_target_id": deploy_target_id,
}
async def _create_single_app(
self,
org_id: str,
user_id: str,
payload: WizardPayload,
defaults: ProfileDefaults,
) -> ProjectRow:
"""Create the one project for this org, raising 409 if one exists."""
existing = await self.session.scalar(
select(ProjectRow).where(ProjectRow.organization_id == org_id)
)
if existing is not None:
raise ConflictError("این سازمان قبلاً یک اپلیکیشن دارد.")
row = ProjectRow(
id=new_id(),
organization_id=org_id,
name=payload.app_name.strip(),
slug=payload.slug.strip(),
created_by_user_id=user_id,
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
runtime_version=(payload.runtime_version or defaults.runtime_version),
package_manager=(payload.package_manager or defaults.package_manager),
setup_command=(payload.setup_command or defaults.setup_command),
build_command=(
payload.build_command
if payload.build_command is not None
else defaults.build_command
),
test_command=(
payload.test_command
if payload.test_command is not None
else defaults.test_command
),
start_command=(payload.start_command or defaults.start_command),
app_port=(payload.app_port if payload.app_port is not None else defaults.app_port),
healthcheck_path=payload.healthcheck_path or defaults.healthcheck_path,
git_source=payload.git_source,
external_repo_url=payload.external_repo_url,
default_branch="main",
wizard_completed_at=utcnow(),
created_at=utcnow(),
)
self.session.add(row)
await self.session.flush()
return row
async def _setup_platform_git(self, project_id: str, repo_name: str) -> None:
if self.git_provider is None:
raise ValidationError("git provider در دسترس نیست.")
repo_info = await self.git_provider.ensure_project_repo(
project_id=project_id, repo_name=repo_name
)
creds = await self.git_provider.ensure_credentials(project_id=project_id)
await self.git_repos.upsert(
ProjectGitRepo(
project_id=project_id,
provider="gitea",
owner=repo_info.owner,
repo_name=repo_info.repo_name,
clone_url=repo_info.clone_url,
web_url=repo_info.web_url,
default_branch=repo_info.default_branch or "main",
)
)
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=creds.username,
encrypted_password=encrypt(creds.password),
)
)
async def _setup_external_git(
self,
project_id: str,
*,
url: str,
auth_type: str,
username: str | None,
secret: str | None,
) -> None:
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
async def _setup_deploy_target(
self, org_id: str, project_id: str, payload: WizardPayload
) -> str:
secret = payload.deploy_target_secret
values = {
"organization_id": org_id,
"project_id": project_id,
"kind": payload.deploy_target_kind,
"host": payload.deploy_target_host or "",
"port": payload.deploy_target_port,
"username": payload.deploy_target_username or "",
"auth_type": payload.deploy_target_auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": payload.deploy_target_remote_path or "/",
"is_active": True,
}
row = await self.deploys.upsert(values=values)
return str(row.id)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
if self.workforce_bootstrap:
try:
await self.workforce_bootstrap.bootstrap_team(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning("Workforce bootstrap failed for %s: %s", organization_id, exc)
if self.delivery_hooks:
try:
await self.delivery_hooks.on_app_created(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Delivery hook on_app_created failed for %s: %s",
organization_id,
exc,
)
# ------------------------------------------------------------------
# App profile editing (post-wizard)
# ------------------------------------------------------------------
async def get_profile(self, *, organization_id: str, project_id: str) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
profile = await AppProfileRepository(self.session).get(
organization_id=organization_id, project_id=project_id
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_profile(
self, *, organization_id: str, project_id: str, values: dict
) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
repo = AppProfileRepository(self.session)
unknown = set(values) - EDITABLE_FIELDS
if unknown:
raise ValidationError(f"فیلدهای غیرقابل ویرایش: {sorted(unknown)}")
profile = await repo.update_profile(
organization_id=organization_id, project_id=project_id, values=values
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_deploy_target(
self,
*,
organization_id: str,
project_id: str,
kind: str,
host: str,
port: int,
username: str,
auth_type: str = "password",
secret: str | None = None,
remote_path: str = "/",
) -> dict:
# Verify project belongs to org
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
values = {
"organization_id": organization_id,
"project_id": project_id,
"kind": kind,
"host": host or "",
"port": port,
"username": username or "",
"auth_type": auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": remote_path or "/",
"is_active": True,
}
target = await self.deploys.upsert(values=values)
return {
"id": target.id,
"kind": target.kind,
"host": target.host,
"port": target.port,
"username": target.username,
"auth_type": target.auth_type,
"remote_path": target.remote_path,
"has_secret": bool(target.secret_encrypted),
"last_test_ok": target.last_test_ok,
"last_test_message": target.last_test_message,
}
async def update_git_credentials(
self,
*,
organization_id: str,
project_id: str,
auth_type: str,
username: str | None = None,
secret: str | None = None,
) -> dict:
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
git_source = (row.git_source or "platform").lower()
if git_source == "platform":
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=username or "",
encrypted_password=encrypt(secret) if secret else "",
)
)
return {"git_source": "platform", "has_secret": bool(secret)}
# external
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
return {"git_source": "external", "has_secret": bool(secret)}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_git_url(url: str) -> None:
"""Lightweight format check before we hand the URL to the git adapter.
Rejects: wrong scheme, embedded credentials, obvious localhost/private IPs.
The DNS-resolved SSRF check lives in ``RepoWorkspacePort.test_remote``.
"""
clean = (url or "").strip()
if not clean:
raise ValidationError("آدرس git نمی‌تواند خالی باشد.")
# Normalise scp-style ``git@host:path`` so urlsplit can parse it.
working = clean
if "://" not in working and re.match(r"^[^/@\s]+@[^/:\s]+:", working):
working = "ssh://" + working.replace(":", "/", 1)
parts = urlsplit(working)
scheme = (parts.scheme or "").lower()
if scheme not in _GIT_SCHEMES:
raise ValidationError("آدرس git باید با https:// یا ssh:// یا git@ شروع شود.")
if parts.password is not None:
raise ValidationError("آدرس git نباید شامل نام کاربری و رمز باشد.")
host = parts.hostname or ""
if not host:
raise ValidationError("میزبان آدرس git مشخص نیست.")
lowered = host.lower().strip(".")
if lowered in {"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"}:
raise ValidationError("اتصال به میزبان محلی مجاز نیست.")
# Reject blocked IP literals without DNS.
try:
import ipaddress
ipaddress.ip_address(lowered.strip("[]"))
except ValueError:
pass
else:
if is_blocked_address(lowered.strip("[]")):
raise ValidationError("اتصال به آدرس‌های داخلی مجاز نیست.")
+1
View File
@@ -21,6 +21,7 @@ class AuditAction:
PASSWORD_CHANGE = "auth.password_change"
ORG_CREATE = "organization.create"
ORG_DELETE = "organization.delete"
ORG_WIZARD_COMPLETE = "organization.wizard_complete"
#: An organization changed hands because its owning manager was demoted or
#: deleted — the platform owner takes over. Never automatic for any other
#: reason.
+403
View File
@@ -0,0 +1,403 @@
"""The org-creation wizard's application catalog.
Pure Python, no I/O: the wizard frontend fetches it from ``GET /setup/catalog``
and the backend uses ``defaults_for`` to prefill — and to *validate* — the app
profile a user submits. Keeping the catalog in one place means the "Next.js"
card and the server's idea of what "Next.js" means can never drift apart, and a
typo in a framework name fails loudly at validation time instead of producing a
half-built project.
The catalog is intentionally conservative: it lists what the platform actually
knows how to build and run, not every language on earth. Each framework carries
enough defaults that a user who accepts the suggestions gets a working app
without touching a terminal.
"""
from __future__ import annotations
from dataclasses import dataclass
#: The kinds of app an organization can host. Mirrors ``APP_TYPES`` in
#: ``app.domain.projects.profile``; declared again here so the catalog is the
#: single source the frontend renders from.
APP_TYPES: tuple[str, ...] = ("web_app", "api", "static_site", "bot", "worker", "other")
#: Display labels (Persian) keyed by app type, for the wizard cards.
APP_TYPE_LABELS: dict[str, str] = {
"web_app": "وب‌اپلیکیشن",
"api": "ای‌پی‌آی",
"static_site": "سایت ایستا",
"bot": "بات",
"worker": "ورکر",
"other": "سایر",
}
GIT_SOURCES: tuple[str, ...] = ("platform", "external")
@dataclass(slots=True, frozen=True, kw_only=True)
class ProfileDefaults:
"""The values ``defaults_for`` hands back for a (type, language, framework)."""
app_type: str
language: str
framework: str
runtime_version: str
package_manager: str
setup_command: str
build_command: str | None
test_command: str | None
start_command: str
app_port: int
healthcheck_path: str
docker_image: str
@property
def needs_build(self) -> bool:
return self.build_command is not None and self.build_command.strip() != ""
@dataclass(slots=True, frozen=True, kw_only=True)
class FrameworkEntry:
"""One selectable framework inside a language group."""
key: str
display: str
defaults: ProfileDefaults
@dataclass(slots=True, frozen=True, kw_only=True)
class LanguageEntry:
"""A language group and the frameworks it offers."""
key: str
display: str
frameworks: tuple[FrameworkEntry, ...]
def _fw(
*,
key: str,
display: str,
app_type: str,
runtime_version: str,
package_manager: str,
setup_command: str,
start_command: str,
app_port: int,
healthcheck_path: str = "/",
build_command: str | None = None,
test_command: str | None = None,
docker_image: str,
) -> FrameworkEntry:
return FrameworkEntry(
key=key,
display=display,
defaults=ProfileDefaults(
app_type=app_type,
language="", # filled by the language group below
framework=key,
runtime_version=runtime_version,
package_manager=package_manager,
setup_command=setup_command,
build_command=build_command,
test_command=test_command,
start_command=start_command,
app_port=app_port,
healthcheck_path=healthcheck_path,
docker_image=docker_image,
),
)
# ---------------------------------------------------------------------------
# Node.js
# ---------------------------------------------------------------------------
_NODE = (
_fw(
key="next",
display="Next.js",
app_type="web_app",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command="npm run test",
start_command="npm run start",
app_port=3000,
healthcheck_path="/",
docker_image="node:20-slim",
),
_fw(
key="express",
display="Express",
app_type="api",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command=None,
test_command="npm run test",
start_command="npm start",
app_port=3000,
healthcheck_path="/health",
docker_image="node:20-slim",
),
_fw(
key="nestjs",
display="NestJS",
app_type="api",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command="npm run test",
start_command="npm run start",
app_port=3000,
healthcheck_path="/health",
docker_image="node:20-slim",
),
_fw(
key="vite-react",
display="Vite + React",
app_type="web_app",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command=None,
start_command="npm run dev -- --host 0.0.0.0",
app_port=5173,
healthcheck_path="/",
docker_image="node:20-slim",
),
)
# ---------------------------------------------------------------------------
# Python
# ---------------------------------------------------------------------------
_PYTHON = (
_fw(
key="fastapi",
display="FastAPI",
app_type="api",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="pytest",
start_command="uvicorn main:app --host 0.0.0.0 --port 8000",
app_port=8000,
healthcheck_path="/health",
docker_image="python:3.12-slim",
),
_fw(
key="django",
display="Django",
app_type="web_app",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="python manage.py test",
start_command="gunicorn config.wsgi:application --bind 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/",
docker_image="python:3.12-slim",
),
_fw(
key="flask",
display="Flask",
app_type="api",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="pytest",
start_command="gunicorn app:app --bind 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/health",
docker_image="python:3.12-slim",
),
)
# ---------------------------------------------------------------------------
# PHP
# ---------------------------------------------------------------------------
_PHP = (
_fw(
key="laravel",
display="Laravel",
app_type="web_app",
runtime_version="8.3",
package_manager="composer",
setup_command="composer install",
build_command="npm install && npm run build",
test_command="php artisan test",
start_command="php artisan serve --host 0.0.0.0 --port 8000",
app_port=8000,
healthcheck_path="/",
docker_image="php:8.3-fpm",
),
_fw(
key="plain",
display="PHP ساده",
app_type="web_app",
runtime_version="8.3",
package_manager="composer",
setup_command="composer install",
build_command=None,
test_command="php -l .",
start_command="php -S 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/",
docker_image="php:8.3-cli",
),
)
# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------
_GO = (
_fw(
key="plain",
display="Go ساده",
app_type="api",
runtime_version="1.22",
package_manager="go mod",
setup_command="go mod download",
build_command="go build -o bin/app ./cmd",
test_command="go test ./...",
start_command="./bin/app",
app_port=8080,
healthcheck_path="/health",
docker_image="golang:1.22",
),
)
# ---------------------------------------------------------------------------
# Static
# ---------------------------------------------------------------------------
_STATIC = (
_fw(
key="html",
display="HTML ایستا",
app_type="static_site",
runtime_version="",
package_manager="",
setup_command="",
build_command=None,
test_command=None,
start_command="",
app_port=80,
healthcheck_path="/",
docker_image="nginx:alpine",
),
)
#: The full catalog, in display order.
LANGUAGES: tuple[LanguageEntry, ...] = (
LanguageEntry(key="node", display="Node.js", frameworks=_NODE),
LanguageEntry(key="python", display="Python", frameworks=_PYTHON),
LanguageEntry(key="php", display="PHP", frameworks=_PHP),
LanguageEntry(key="go", display="Go", frameworks=_GO),
LanguageEntry(key="static", display="ایستا", frameworks=_STATIC),
)
#: Fast lookup ``(language, framework) -> ProfileDefaults``.
_LOOKUP: dict[tuple[str, str], ProfileDefaults] = {}
def _build_lookup() -> None:
from dataclasses import asdict
for lang in LANGUAGES:
for fw in lang.frameworks:
base = asdict(fw.defaults)
base["language"] = lang.key
defaults = ProfileDefaults(**base)
_LOOKUP[(lang.key, fw.key)] = defaults
_build_lookup()
def defaults_for(*, app_type: str, language: str, framework: str) -> ProfileDefaults:
"""Resolve the catalog defaults for a concrete choice.
Raises ``ValueError`` when the combination is not in the catalog — the
backend calls this while validating the wizard's ``complete`` payload, so an
unknown framework (a stale frontend, a hand-crafted request) is rejected
before anything is written.
"""
key = (language, framework)
if key not in _LOOKUP:
raise ValueError(
f"چارچوب ناشناخته: language={language!r} framework={framework!r}"
)
found = _LOOKUP[key]
if found.app_type != app_type:
raise ValueError(
f"نوع اپلیکیشن با چارچوب هم‌خوانی ندارد: انتظار {found.app_type!r}، دریافت {app_type!r}"
)
return found
def is_known_language(language: str) -> bool:
return any(lang.key == language for lang in LANGUAGES)
def is_known_framework(language: str, framework: str) -> bool:
return (language, framework) in _LOOKUP
def catalog_payload() -> dict:
"""Structure returned by ``GET /setup/catalog``."""
def _framework_out(fw: FrameworkEntry) -> dict:
d = fw.defaults
return {
"key": fw.key,
"display": fw.display,
"app_type": d.app_type,
"defaults": {
"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": d.app_port,
"healthcheck_path": d.healthcheck_path,
"docker_image": d.docker_image,
},
}
return {
"app_types": list(APP_TYPES),
"app_type_labels": APP_TYPE_LABELS,
"git_sources": list(GIT_SOURCES),
"languages": [
{
"key": lang.key,
"display": lang.display,
"frameworks": [_framework_out(fw) for fw in lang.frameworks],
}
for lang in LANGUAGES
],
}
__all__ = [
"APP_TYPES",
"APP_TYPE_LABELS",
"GIT_SOURCES",
"LANGUAGES",
"FrameworkEntry",
"LanguageEntry",
"ProfileDefaults",
"catalog_payload",
"defaults_for",
"is_known_framework",
"is_known_language",
]
+28 -1
View File
@@ -76,4 +76,31 @@ class PlatformInviteRow(Base):
return self.expires_at <= reference
__all__ = ["PlatformInviteRow"]
class OrgSetupDraftRow(Base):
"""A user's in-progress org-creation wizard state.
The wizard autosaves its current step here so a refresh resumes. By design
the payload never contains secrets: git tokens, SSH keys and deploy-target
passwords are submitted only on the final ``complete`` call and are held
encrypted elsewhere (``project_external_git`` / ``deploy_targets``). Storing
them in the draft would defeat the encryption at rest.
"""
__tablename__ = "org_setup_drafts"
id: Mapped[str] = pk()
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
#: The furthest step the user reached (1..5). Lets the frontend jump back.
step: Mapped[int] = mapped_column(default=1, nullable=False)
#: JSON blob of the non-secret wizard fields. Validated upstream; secrets
#: are stripped before this row is written.
data_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
created_at: Mapped[datetime] = created()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
@@ -6,6 +6,442 @@ route its full path (no prefix is applied here besides ``/api``).
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_delivery import (
build_delivery_hooks,
build_deploy_target_tester,
build_repo_workspace,
)
from app.application.container_engine import build_workforce_bootstrap
from app.application.services.audit_service import record_audit
from app.application.services.org_setup_service import OrgSetupService, WizardPayload
from app.core.database import get_session
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS
from app.domain.projects.catalog import APP_TYPES, GIT_SOURCES
from app.infrastructure.adapters.registry import get_git_provider
from app.infrastructure.db.models import ProjectRow
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_current_user,
get_org_context,
require_capability,
)
router = APIRouter(tags=["app-setup"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class CatalogOut(BaseModel):
model_config = ConfigDict(extra="ignore")
catalog: dict
class DraftRequest(BaseModel):
step: int = Field(ge=1, le=5)
data: dict = {}
class DraftOut(BaseModel):
step: int
data: dict
class CheckGitRequest(BaseModel):
url: str = Field(min_length=1, max_length=500)
auth_type: str = "none"
username: str | None = None
secret: str | None = None
class CheckDeployRequest(BaseModel):
kind: str = Field(min_length=1)
host: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
username: str = Field(min_length=1)
auth_type: str = "password"
secret: str | None = None
remote_path: str = "/"
class WizardCompleteRequest(BaseModel):
org_name: str = Field(min_length=2, max_length=160)
slug: str = Field(min_length=2, max_length=120, pattern=r"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")
app_name: str = Field(min_length=1, max_length=160)
app_type: str = Field(min_length=1)
language: str = Field(min_length=1)
framework: str = Field(min_length=1)
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
external_git_auth_type: str = "token"
external_git_username: str | None = None
external_git_secret: str | None = None
deploy_target_kind: str | None = None
deploy_target_host: str | None = None
deploy_target_port: int = 22
deploy_target_username: str | None = None
deploy_target_auth_type: str = "password"
deploy_target_secret: str | None = None
deploy_target_remote_path: str = "/"
@field_validator("app_type")
@classmethod
def _valid_app_type(cls, v: str) -> str:
if v not in APP_TYPES:
raise ValueError("نوع اپلیکیشن نامعتبر است.")
return v
@field_validator("git_source")
@classmethod
def _valid_git_source(cls, v: str) -> str:
if v not in GIT_SOURCES:
raise ValueError("منبع git نامعتبر است.")
return v
class WizardCompleteOut(BaseModel):
organization: dict
project: dict
deploy_target_id: str | None = None
class ProfileOut(BaseModel):
model_config = ConfigDict(extra="ignore")
project_id: str
app_type: str | None = None
language: str | None = None
framework: str | None = None
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
default_branch: str = "main"
wizard_completed: bool = False
class ProfilePatchRequest(BaseModel):
app_type: str | None = None
language: str | None = None
framework: str | None = None
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str | None = None
git_source: str | None = None
external_repo_url: str | None = None
default_branch: str | None = None
class DeployTargetOut(BaseModel):
id: str
kind: str
host: str
port: int
username: str
auth_type: str
remote_path: str
has_secret: bool
last_test_ok: bool | None = None
last_test_message: str | None = None
class DeployTargetPutRequest(BaseModel):
kind: str = Field(min_length=1)
host: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
username: str = Field(min_length=1)
auth_type: str = "password"
secret: str | None = None
remote_path: str = "/"
class GitCredentialsPutRequest(BaseModel):
auth_type: str = "token"
username: str | None = None
secret: str | None = None
class GitCredentialsOut(BaseModel):
git_source: str
has_secret: bool
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_service(session: AsyncSession) -> OrgSetupService:
return OrgSetupService(
session,
repo_workspace=build_repo_workspace(session),
deploy_tester=build_deploy_target_tester(session),
git_provider=get_git_provider(),
workforce_bootstrap=build_workforce_bootstrap(session),
delivery_hooks=build_delivery_hooks(session),
)
async def _project_for_org(session: AsyncSession, org_id: str) -> ProjectRow:
row = await session.scalar(
select(ProjectRow).where(ProjectRow.organization_id == org_id)
)
if row is None:
from app.core.errors import NotFoundError
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
return row
# ---------------------------------------------------------------------------
# Wizard routes (no org context — the org does not exist yet)
# ---------------------------------------------------------------------------
@router.get("/setup/catalog", response_model=dict)
async def get_catalog(
_user=Depends(get_current_user),
) -> dict:
return OrgSetupService.catalog()
@router.get("/setup/draft", response_model=DraftOut)
async def get_draft(
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> DraftOut:
svc = OrgSetupService(session)
data = await svc.get_draft(user.id)
return DraftOut(step=data["step"], data=data["data"])
@router.put("/setup/draft", status_code=status.HTTP_204_NO_CONTENT)
async def save_draft(
payload: DraftRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
svc = OrgSetupService(session)
await svc.save_draft(user.id, step=payload.step, data=payload.data)
@router.post("/setup/check-git", response_model=dict)
async def check_git(
payload: CheckGitRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
svc = _build_service(session)
result = await svc.check_git(
url=payload.url,
auth_type=payload.auth_type,
username=payload.username,
secret=payload.secret,
)
return {
"ok": result.ok,
"default_branch": result.default_branch,
"branches": result.branches,
"message": result.message,
}
@router.post("/setup/check-deploy", response_model=dict)
async def check_deploy(
payload: CheckDeployRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
from app.application.ports.deploy_target import DeployTargetSpec
svc = _build_service(session)
result = await svc.check_deploy(
target=DeployTargetSpec(
kind=payload.kind,
host=payload.host,
port=payload.port,
username=payload.username,
auth_type=payload.auth_type,
secret=payload.secret,
remote_path=payload.remote_path,
)
)
return {
"ok": result.ok,
"message": result.message,
"fingerprint": result.fingerprint,
}
@router.post(
"/setup/complete",
response_model=WizardCompleteOut,
status_code=status.HTTP_201_CREATED,
)
async def complete_wizard(
payload: WizardCompleteRequest,
request: Request,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> WizardCompleteOut:
svc = _build_service(session)
result = await svc.complete(
user,
WizardPayload(
org_name=payload.org_name,
slug=payload.slug,
app_name=payload.app_name,
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
runtime_version=payload.runtime_version,
package_manager=payload.package_manager,
setup_command=payload.setup_command,
build_command=payload.build_command,
test_command=payload.test_command,
start_command=payload.start_command,
app_port=payload.app_port,
healthcheck_path=payload.healthcheck_path,
git_source=payload.git_source,
external_repo_url=payload.external_repo_url,
external_git_auth_type=payload.external_git_auth_type,
external_git_username=payload.external_git_username,
external_git_secret=payload.external_git_secret,
deploy_target_kind=payload.deploy_target_kind,
deploy_target_host=payload.deploy_target_host,
deploy_target_port=payload.deploy_target_port,
deploy_target_username=payload.deploy_target_username,
deploy_target_auth_type=payload.deploy_target_auth_type,
deploy_target_secret=payload.deploy_target_secret,
deploy_target_remote_path=payload.deploy_target_remote_path,
),
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ORG_WIZARD_COMPLETE,
organization_id=result["organization"]["id"],
actor_user_id=user.id,
target_type="organization",
target_id=result["organization"]["id"],
metadata={
"org_name": payload.org_name,
"slug": payload.slug,
"app_name": payload.app_name,
"app_type": payload.app_type,
"language": payload.language,
"framework": payload.framework,
},
ip=ip,
user_agent=ua,
)
return WizardCompleteOut(
organization=result["organization"],
project=result["project"],
deploy_target_id=result.get("deploy_target_id"),
)
# ---------------------------------------------------------------------------
# App profile editing (org-scoped)
# ---------------------------------------------------------------------------
@router.get("/organizations/{org_id}/app/profile", response_model=ProfileOut)
async def get_app_profile(
org_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> ProfileOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.get_profile(organization_id=org_id, project_id=project.id)
return ProfileOut(**data)
@router.patch("/organizations/{org_id}/app/profile", response_model=ProfileOut)
async def patch_app_profile(
org_id: str,
payload: ProfilePatchRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> ProfileOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
values = {k: v for k, v in payload.model_dump().items() if v is not None}
data = await svc.update_profile(
organization_id=org_id, project_id=project.id, values=values
)
return ProfileOut(**data)
@router.put("/organizations/{org_id}/app/deploy-target", response_model=DeployTargetOut)
async def put_deploy_target(
org_id: str,
payload: DeployTargetPutRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> DeployTargetOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.update_deploy_target(
organization_id=org_id,
project_id=project.id,
kind=payload.kind,
host=payload.host,
port=payload.port,
username=payload.username,
auth_type=payload.auth_type,
secret=payload.secret,
remote_path=payload.remote_path,
)
return DeployTargetOut(**data)
@router.put("/organizations/{org_id}/app/git", response_model=GitCredentialsOut)
async def put_git_credentials(
org_id: str,
payload: GitCredentialsPutRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> GitCredentialsOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.update_git_credentials(
organization_id=org_id,
project_id=project.id,
auth_type=payload.auth_type,
username=payload.username,
secret=payload.secret,
)
return GitCredentialsOut(**data)
+386
View File
@@ -0,0 +1,386 @@
"""§v2-A1.2 — org creation wizard + app profile (1 org = 1 app).
Covers the catalog, draft, git/deploy checks, wizard completion, the 1-app guard,
SSRF prevention on git URLs, and the post-wizard profile editing endpoints.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from httpx import AsyncClient
from app.domain.identity.models import PlatformRole
from app.domain.projects.catalog import APP_TYPES
from tests.conftest import auth_header, signup
@pytest_asyncio.fixture(autouse=True)
async def _seeded(seeded):
return seeded
async def _manager(client: AsyncClient) -> tuple[str, dict]:
token = (await signup(client, "wiz-manager@example.com"))["access_token"]
headers = auth_header(token)
return token, headers
async def _user(client: AsyncClient) -> tuple[str, dict]:
token = (
await signup(
client, "wiz-user@example.com", platform_role=PlatformRole.USER
)
)["access_token"]
headers = auth_header(token)
return token, headers
async def _complete(
client: AsyncClient, headers: dict, overrides: dict | None = None
) -> dict:
payload = {
"org_name": "سازمان تست",
"slug": "test-org",
"app_name": "اپ تست",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
if overrides:
payload.update(overrides)
response = await client.post("/api/setup/complete", json=payload, headers=headers)
assert response.status_code == 201, response.text
return response.json()
# ---------------------------------------------------------------------------
# Catalog
# ---------------------------------------------------------------------------
async def test_catalog_returns_app_types_and_languages(client: AsyncClient):
token, headers = await _manager(client)
response = await client.get("/api/setup/catalog", headers=headers)
assert response.status_code == 200
data = response.json()
assert set(data["app_types"]) == set(APP_TYPES)
assert "languages" in data
lang_keys = {lang["key"] for lang in data["languages"]}
assert "node" in lang_keys
assert "python" in lang_keys
# next.js should carry defaults
node = next(lang for lang in data["languages"] if lang["key"] == "node")
next_fw = next(fw for fw in node["frameworks"] if fw["key"] == "next")
assert next_fw["defaults"]["app_port"] == 3000
# ---------------------------------------------------------------------------
# Draft
# ---------------------------------------------------------------------------
async def test_draft_save_and_resume(client: AsyncClient):
token, headers = await _manager(client)
await client.put(
"/api/setup/draft", json={"step": 3, "data": {"org_name": "X"}}, headers=headers
)
response = await client.get("/api/setup/draft", headers=headers)
assert response.status_code == 200
data = response.json()
assert data["step"] == 3
assert data["data"]["org_name"] == "X"
# ---------------------------------------------------------------------------
# Wizard completion — happy paths
# ---------------------------------------------------------------------------
async def test_complete_platform_git_creates_org_and_project(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(client, headers)
assert result["organization"]["name"] == "سازمان تست"
assert result["project"]["name"] == "اپ تست"
assert result["deploy_target_id"] is None
# Profile is readable via the org-scoped endpoint
org_id = result["organization"]["id"]
resp = await client.get(f"/api/organizations/{org_id}/app/profile", headers=headers)
assert resp.status_code == 200
profile = resp.json()
assert profile["app_type"] == "web_app"
assert profile["language"] == "node"
assert profile["framework"] == "next"
assert profile["wizard_completed"] is True
async def test_complete_with_external_git(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"git_source": "external",
"external_repo_url": "https://git.example.invalid/repo.git",
"external_git_auth_type": "token",
"external_git_username": "user",
"external_git_secret": "my-secret-token",
},
)
org_id = result["organization"]["id"]
resp = await client.get(f"/api/organizations/{org_id}/app/profile", headers=headers)
assert resp.status_code == 200
assert resp.json()["git_source"] == "external"
assert resp.json()["external_repo_url"] == "https://git.example.invalid/repo.git"
async def test_complete_with_deploy_target(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"deploy_target_kind": "ssh",
"deploy_target_host": "deploy.example.invalid",
"deploy_target_port": 22,
"deploy_target_username": "root",
"deploy_target_secret": "deploy-secret",
},
)
assert result["deploy_target_id"] is not None
async def test_complete_does_not_block_on_failing_deploy_test(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"deploy_target_kind": "ssh",
"deploy_target_host": "fail.example.invalid",
"deploy_target_port": 22,
"deploy_target_username": "root",
"deploy_target_secret": "deploy-secret",
},
)
# The fake deploy tester returns ok=False for hosts containing "fail",
# but the wizard stores the target regardless (last_test_ok=False).
assert result["deploy_target_id"] is not None
# ---------------------------------------------------------------------------
# Guards
# ---------------------------------------------------------------------------
async def test_plain_user_cannot_complete_wizard(client: AsyncClient):
token, headers = await _user(client)
payload = {
"org_name": "سازمان تست",
"slug": "test-org-user",
"app_name": "اپ تست",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
response = await client.post("/api/setup/complete", json=payload, headers=headers)
assert response.status_code == 403
async def test_second_app_for_same_org_returns_409(client: AsyncClient, session_factory):
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "test-409"})
org_id = result["organization"]["id"]
from app.application.services.org_setup_service import OrgSetupService, WizardPayload
from app.core.errors import ConflictError
from app.domain.projects.catalog import ProfileDefaults
async with session_factory() as session:
svc = OrgSetupService(session)
defaults = ProfileDefaults(
app_type="api", language="python", framework="fastapi",
runtime_version="3.12", package_manager="pip",
setup_command="", build_command=None, test_command=None,
start_command="", app_port=8000, healthcheck_path="/",
docker_image="python:3.12-slim",
)
with pytest.raises(ConflictError, match="این سازمان قبلاً"):
await svc._create_single_app(
org_id=org_id,
user_id="ignored",
payload=WizardPayload(
org_name="X", slug="x", app_name="Y",
app_type="api", language="python", framework="fastapi",
),
defaults=defaults,
)
# ---------------------------------------------------------------------------
# Secrets never leak
# ---------------------------------------------------------------------------
async def test_secrets_never_appear_in_wizard_responses(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"slug": "secret-check",
"git_source": "external",
"external_repo_url": "https://git.example.invalid/repo.git",
"external_git_secret": "SUPER-SECRET-123",
"deploy_target_kind": "ssh",
"deploy_target_host": "deploy.example.invalid",
"deploy_target_secret": "DEPLOY-SECRET-456",
},
)
# Audit endpoint must not contain the secret
org_id = result["organization"]["id"]
audit = await client.get(f"/api/organizations/{org_id}/audit", headers=headers)
assert audit.status_code == 200
for event in audit.json():
raw = str(event)
assert "SUPER-SECRET-123" not in raw
assert "DEPLOY-SECRET-456" not in raw
# Draft must not contain the secret
draft = await client.get("/api/setup/draft", headers=headers)
assert draft.status_code == 200
raw_draft = str(draft.json())
assert "SUPER-SECRET-123" not in raw_draft
assert "DEPLOY-SECRET-456" not in raw_draft
# ---------------------------------------------------------------------------
# SSRF on git URL
# ---------------------------------------------------------------------------
async def test_check_git_rejects_localhost(client: AsyncClient):
token, headers = await _manager(client)
response = await client.post(
"/api/setup/check-git",
json={"url": "https://localhost/repo.git"},
headers=headers,
)
assert response.status_code == 422 or response.status_code == 400
async def test_check_git_rejects_private_ip(client: AsyncClient):
token, headers = await _manager(client)
response = await client.post(
"/api/setup/check-git",
json={"url": "https://192.168.1.1/repo.git"},
headers=headers,
)
assert response.status_code == 422 or response.status_code == 400
async def test_check_git_rejects_credentials_in_url(client: AsyncClient):
token, headers = await _manager(client)
response = await client.post(
"/api/setup/check-git",
json={"url": "https://user:pass@github.com/repo.git"},
headers=headers,
)
assert response.status_code == 422 or response.status_code == 400
# ---------------------------------------------------------------------------
# Profile editing
# ---------------------------------------------------------------------------
async def test_patch_profile_updates_fields(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "patch-test"})
org_id = result["organization"]["id"]
resp = await client.patch(
f"/api/organizations/{org_id}/app/profile",
json={"app_port": 9000, "healthcheck_path": "/ready"},
headers=headers,
)
assert resp.status_code == 200
profile = resp.json()
assert profile["app_port"] == 9000
assert profile["healthcheck_path"] == "/ready"
async def test_put_deploy_target_rotates_secret(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "deploy-test"})
org_id = result["organization"]["id"]
resp = await client.put(
f"/api/organizations/{org_id}/app/deploy-target",
json={
"kind": "sftp",
"host": "sftp.example.invalid",
"port": 22,
"username": "admin",
"secret": "new-secret",
"remote_path": "/var/www",
},
headers=headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["kind"] == "sftp"
assert data["has_secret"] is True
async def test_put_git_credentials_rotates_secret(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"slug": "git-cred-test",
"git_source": "external",
"external_repo_url": "https://git.example.invalid/repo.git",
},
)
org_id = result["organization"]["id"]
resp = await client.put(
f"/api/organizations/{org_id}/app/git",
json={"auth_type": "token", "username": "newuser", "secret": "new-token"},
headers=headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["git_source"] == "external"
assert data["has_secret"] is True
# ---------------------------------------------------------------------------
# Hook recording
# ---------------------------------------------------------------------------
async def test_hooks_called_after_wizard_complete(client: AsyncClient, monkeypatch):
from app.application.services.org_setup_service import OrgSetupService
hook_calls: list[dict] = []
async def _record_hooks(self, *, organization_id: str, project_id: str) -> None:
hook_calls.append({"organization_id": organization_id, "project_id": project_id})
monkeypatch.setattr(OrgSetupService, "_call_hooks", _record_hooks)
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "hook-test"})
org_id = result["organization"]["id"]
project_id = result["project"]["id"]
assert any(
c["organization_id"] == org_id and c["project_id"] == project_id
for c in hook_calls
)
+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());
});
});