From 5fb85d6eed93ad73c76eaa319f190c69f09ee0cd Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Wed, 14 Oct 2026 10:46:47 +0330 Subject: [PATCH] feat(org): org creation wizard + app profile (v2 phase A1-2) --- .../versions/d5e6f7a8b9c0_org_setup_drafts.py | 43 + .../application/services/org_setup_service.py | 596 +++++++++++++ apps/api/app/domain/audit/models.py | 1 + apps/api/app/domain/projects/catalog.py | 403 +++++++++ apps/api/app/infrastructure/db/models_org.py | 29 +- .../app/interfaces/http/routers/app_setup.py | 438 ++++++++- apps/api/tests/test_org_wizard.py | 386 ++++++++ apps/web/src/app/(app)/settings/page.tsx | 350 +++++++- apps/web/src/app/onboarding/page.tsx | 838 ++++++++++++++++-- apps/web/src/lib/api-org.ts | 76 ++ apps/web/src/test/org-wizard.test.tsx | 170 ++++ docs/plan/reports/agent-1-phase-2.md | 70 ++ packages/contracts/src/index.ts | 156 ++++ 13 files changed, 3485 insertions(+), 71 deletions(-) create mode 100644 apps/api/alembic/versions/d5e6f7a8b9c0_org_setup_drafts.py create mode 100644 apps/api/app/application/services/org_setup_service.py create mode 100644 apps/api/app/domain/projects/catalog.py create mode 100644 apps/api/tests/test_org_wizard.py create mode 100644 apps/web/src/test/org-wizard.test.tsx create mode 100644 docs/plan/reports/agent-1-phase-2.md diff --git a/apps/api/alembic/versions/d5e6f7a8b9c0_org_setup_drafts.py b/apps/api/alembic/versions/d5e6f7a8b9c0_org_setup_drafts.py new file mode 100644 index 0000000..fe3f94a --- /dev/null +++ b/apps/api/alembic/versions/d5e6f7a8b9c0_org_setup_drafts.py @@ -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") diff --git a/apps/api/app/application/services/org_setup_service.py b/apps/api/app/application/services/org_setup_service.py new file mode 100644 index 0000000..2028168 --- /dev/null +++ b/apps/api/app/application/services/org_setup_service.py @@ -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("اتصال به آدرس‌های داخلی مجاز نیست.") diff --git a/apps/api/app/domain/audit/models.py b/apps/api/app/domain/audit/models.py index e60980f..31f8db8 100644 --- a/apps/api/app/domain/audit/models.py +++ b/apps/api/app/domain/audit/models.py @@ -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. diff --git a/apps/api/app/domain/projects/catalog.py b/apps/api/app/domain/projects/catalog.py new file mode 100644 index 0000000..14d1639 --- /dev/null +++ b/apps/api/app/domain/projects/catalog.py @@ -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", +] diff --git a/apps/api/app/infrastructure/db/models_org.py b/apps/api/app/infrastructure/db/models_org.py index 663b9e0..4e4c0a7 100644 --- a/apps/api/app/infrastructure/db/models_org.py +++ b/apps/api/app/infrastructure/db/models_org.py @@ -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"] diff --git a/apps/api/app/interfaces/http/routers/app_setup.py b/apps/api/app/interfaces/http/routers/app_setup.py index da34900..216881c 100644 --- a/apps/api/app/interfaces/http/routers/app_setup.py +++ b/apps/api/app/interfaces/http/routers/app_setup.py @@ -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) diff --git a/apps/api/tests/test_org_wizard.py b/apps/api/tests/test_org_wizard.py new file mode 100644 index 0000000..137694a --- /dev/null +++ b/apps/api/tests/test_org_wizard.py @@ -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 + ) diff --git a/apps/web/src/app/(app)/settings/page.tsx b/apps/web/src/app/(app)/settings/page.tsx index b94e28b..dd50e04 100644 --- a/apps/web/src/app/(app)/settings/page.tsx +++ b/apps/web/src/app/(app)/settings/page.tsx @@ -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 = { @@ -62,7 +64,7 @@ const AUDIT_LABELS: Record = { "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 (
@@ -218,6 +221,7 @@ export default function SettingsPage() { {( [ ["members", "اعضا و دعوت"], + ["app", "اپلیکیشن"], ["audit", "رویدادهای حسابرسی"], ] as const ).map(([value, label]) => ( @@ -372,6 +376,8 @@ export default function SettingsPage() { )} + ) : tab === "app" ? ( + ) : ( رویدادهای حسابرسی @@ -422,3 +428,343 @@ export default function SettingsPage() {
); } + +/** + * "اپلیکیشن" 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(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [form, setForm] = React.useState>({}); + 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(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(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 = {}; + 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 ( + + + + ); + } + + if (!profile) { + return ( + + + + ); + } + + const readOnly = !canEdit; + + return ( +
+ {error && } + + {readOnly && ( + + فقط مالک یا مدیر سازمان می‌تواند پروفایل اپلیکیشن را ویرایش کند. + + )} + + {/* Profile */} + + پروفایل اپلیکیشن + + این‌ها همان تنظیماتی هستند که در جادوگر وارد کردید. منبع گیت فقط قابل چرخش + اعتبارنامه است و پس از ساخت تغییر نمی‌کند. + +
+
+ setField("app_type", e.target.value)} className="ltr-inline" /> + setField("language", e.target.value)} className="ltr-inline" /> + setField("framework", e.target.value)} className="ltr-inline" /> + setField("runtime_version", e.target.value)} className="ltr-inline" /> + setField("package_manager", e.target.value)} className="ltr-inline" /> + setField("app_port", e.target.value)} className="ltr-inline" /> +
+ setField("setup_command", e.target.value)} className="ltr-inline" /> + setField("build_command", e.target.value)} className="ltr-inline" /> + setField("test_command", e.target.value)} className="ltr-inline" /> + setField("start_command", e.target.value)} className="ltr-inline" /> +
+ setField("healthcheck_path", e.target.value)} className="ltr-inline" /> + setField("default_branch", e.target.value)} className="ltr-inline" /> +
+ setField("external_repo_url", e.target.value)} className="ltr-inline" /> + {canEdit && ( + + )} +
+
+ + {/* Git credentials rotation */} + + اعتبارنامهٔ گیت + + منبع فعلی: {profile.git_source === "platform" ? "Git پلتفرم" : "مخزن خودم"}. تغییر + منبع پس از ساخت امکان‌پذیر نیست؛ فقط اعتبارنامه را بچرخانید. + +
+ setDt((p) => ({ ...p, kind: e.target.value }))} + options={[ + { value: "ssh", label: "SSH" }, + { value: "sftp", label: "SFTP" }, + { value: "ftp", label: "FTP" }, + { value: "ftps", label: "FTPS" }, + ]} + /> +
+ setDt((p) => ({ ...p, host: e.target.value }))} className="ltr-inline" /> + setDt((p) => ({ ...p, port: e.target.value }))} className="ltr-inline" /> + setDt((p) => ({ ...p, username: e.target.value }))} className="ltr-inline" /> +
+ update("app_type", e.target.value)} + options={[ + { value: "", label: "انتخاب کنید…" }, + ...catalog.app_types.map((t) => ({ + value: t, + label: catalog.app_type_labels[t] ?? t, + })), + ]} + /> + { + 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 })) ?? []), + ]} + /> + + + + {showAdvanced && ( +
+
+ update("runtime_version", e.target.value)} className="ltr-inline" /> + update("package_manager", e.target.value)} className="ltr-inline" /> +
+ update("setup_command", e.target.value)} className="ltr-inline" /> + update("build_command", e.target.value)} className="ltr-inline" /> + update("test_command", e.target.value)} className="ltr-inline" /> + update("start_command", e.target.value)} className="ltr-inline" /> +
+ update("app_port", e.target.value)} className="ltr-inline" /> + update("healthcheck_path", e.target.value)} className="ltr-inline" /> +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Step 3 — Git +// --------------------------------------------------------------------------- + +function GitStep({ + form, + update, + gitCheck, + gitChecking, + onCheck, +}: { + form: WizardForm; + update: (k: K, v: WizardForm[K]) => void; + gitCheck: { ok: boolean; message?: string } | null; + gitChecking: boolean; + onCheck: () => void; +}) { + return ( +
+ مخزن گیت اپلیکیشن را انتخاب کنید. گیت را همیشه پلتفرم مدیریت می‌کند. + update("external_git_auth_type", e.target.value)} + options={[ + { value: "token", label: "توکن (access token)" }, + { value: "ssh", label: "کلید SSH" }, + ]} + /> + update("external_git_username", e.target.value)} + className="ltr-inline" + /> + update("external_git_secret", e.target.value)} + hint="رمز فقط در درخواست نهایی فرستاده می‌شود و هرگز در پیش‌نویس ذخیره نمی‌گردد." + className="ltr-inline" + /> +
+ +
+ {gitCheck && ( +

+ {gitCheck.ok ? "اتصال برقرار است." : gitCheck.message} +

+ )} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Step 4 — Production (optional) +// --------------------------------------------------------------------------- + +function ProductionStep({ + form, + update, + deployCheck, + deployChecking, + onCheck, +}: { + form: WizardForm; + update: (k: K, v: WizardForm[K]) => void; + deployCheck: { ok: boolean; message?: string } | null; + deployChecking: boolean; + onCheck: () => void; +}) { + const skip = !form.deploy_kind; + return ( +
+ + انتشار در تولید فقط وقتی انجام می‌شود که خودتان دکمه را بزنید. اینجا فقط تنظیمات را + ثبت می‌کنید؛ هیچ کدی بدون کلیک شما به تولید نمی‌رود. + + + update("deploy_auth_type", e.target.value)} + options={[ + { value: "password", label: "رمز عبور" }, + { value: "ssh_key", label: "کلید SSH" }, + ]} + /> + update("deploy_secret", e.target.value)} + className="ltr-inline" + /> + update("deploy_remote_path", e.target.value)} className="ltr-inline" /> +
+ +
+ {deployCheck && ( +

+ {deployCheck.ok ? "اتصال برقرار است." : deployCheck.message} +

+ )} +
+ )} + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ همه‌چیز درست است؟ با زدن «ساخت سازمان» ثبت نهایی انجام می‌شود. +
+ {rows.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/lib/api-org.ts b/apps/web/src/lib/api-org.ts index 4edfb48..030a0fc 100644 --- a/apps/web/src/lib/api-org.ts +++ b/apps/web/src/lib/api-org.ts @@ -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("/api/setup/catalog"), + + /** Resume an in-progress draft (step + collected fields, no secrets). */ + getDraft: () => apiRequest("/api/setup/draft"), + + /** Persist the current step + fields. Returns nothing (204). */ + saveDraft: (step: number, data: Record) => + apiRequest("/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("/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(`/api/organizations/${orgId}/app/profile`), + + update: (orgId: string, values: AppProfileUpdate) => + apiRequest(`/api/organizations/${orgId}/app/profile`, { + method: "PATCH", + body: values, + }), + + putDeployTarget: (orgId: string, body: DeployTargetPutBody) => + apiRequest(`/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. diff --git a/apps/web/src/test/org-wizard.test.tsx b/apps/web/src/test/org-wizard.test.tsx new file mode 100644 index 0000000..d8b1337 --- /dev/null +++ b/apps/web/src/test/org-wizard.test.tsx @@ -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, + organizations: [] as Array>, + activeOrganization: null as Record | 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) => 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 }) => ( + {children} + ), +})); + +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(); + await waitFor(() => expect(screen.getByText("نام سازمان")).toBeInTheDocument()); + expect(mocks.setupApi.getCatalog).toHaveBeenCalledTimes(1); + }); + + it("auto-suggests a slug from the org name", async () => { + render(); + 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(); + 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(); + + 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; + // 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(); + 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(); + await waitFor(() => expect(screen.getByText("در انتظار دعوت")).toBeInTheDocument()); + }); +}); diff --git a/docs/plan/reports/agent-1-phase-2.md b/docs/plan/reports/agent-1-phase-2.md new file mode 100644 index 0000000..02c5602 --- /dev/null +++ b/docs/plan/reports/agent-1-phase-2.md @@ -0,0 +1,70 @@ +# Agent 1 · Phase 2 — Org creation wizard + app profile (v2 A1-2) + +**Status:** done. Backend + frontend + tests all green. +**Branch:** `dev` (Gitea auto-deploys `dev`). +**Checks:** `ruff` clean on new `.py`; `test_org_wizard.py` 16/16 pass; web `tsc --noEmit` clean; `next lint` clean; `org-wizard.test.tsx` 6/6 pass; `org-sharing.test.tsx` 5/5 pass. + +--- + +## Endpoints added (method + path + role) + +| Method | Path | Who | +|---|---|---| +| GET | `/api/setup/catalog` | any authenticated user | +| GET | `/api/setup/draft` | any authenticated user (row is user-scoped) | +| PUT | `/api/setup/draft` | any authenticated user (204, no secrets persisted) | +| POST | `/api/setup/check-git` | any authenticated user (URL format + SSRF check server-side) | +| POST | `/api/setup/check-deploy` | any authenticated user (TOFU fingerprint on success) | +| POST | `/api/setup/complete` | any authenticated user; **403** for role `user`, **409** if the caller already owns an app | +| GET | `/api/organizations/{org}/app/profile` | org member | +| PATCH | `/api/organizations/{org}/app/profile` | `CAN_MANAGE_PROJECTS` (owner/admin/manager) | +| PUT | `/api/organizations/{org}/app/deploy-target` | `CAN_MANAGE_PROJECTS` | +| PUT | `/api/organizations/{org}/app/git` | `CAN_MANAGE_PROJECTS` (credential rotation only — git source is fixed after creation) | + +Audit action `organization.wizard_complete` (`ORG_WIZARD_COMPLETE`) is written on complete. + +## Tables / migrations + +- New `org_setup_drafts` table via migration `d5e6f7a8b9c0_org_setup_drafts.py` + (`down_revision="a1b2c3d4e5f1"`, the Phase 1 head). Columns: `id, user_id (FK users CASCADE), step, data_json, created_at, updated_at`, index `ix_org_setup_drafts_user_id`. +- App-profile columns on `projects` (`app_type, language, framework, runtime_version, package_manager, setup/build/test/start_command, app_port, healthcheck_path, git_source, external_repo_url, wizard_completed_at`) — added in the v2 foundation (TODO-0), not this phase. +- `UNIQUE(organization_id)` on `projects` enforces 1 org = 1 app; the service returns a friendly **409** Persian message (`این سازمان قبلاً یک اپلیکیشن دارد.`) instead of a raw constraint error. + +## Files + +Backend +- `app/domain/projects/catalog.py` — pure-Python catalog (2.1): `APP_TYPES`, `GIT_SOURCES`, `LANGUAGES` → frameworks → `ProfileDefaults`; `defaults_for(...)`. +- `app/infrastructure/db/models_org.py` — `OrgSetupDraftRow`. +- `alembic/versions/d5e6f7a8b9c0_org_setup_drafts.py` — migration. +- `app/application/services/org_setup_service.py` — `OrgSetupService` (`catalog, get_draft, save_draft, check_git, check_deploy, complete, get_profile, update_profile, update_deploy_target, update_git_credentials`) + `WizardPayload`. +- `app/interfaces/http/routers/app_setup.py` — all routes above (was a stub, pre-registered in `rpc_app.py`). +- `app/domain/audit/models.py` — `ORG_WIZARD_COMPLETE`. +- `tests/test_org_wizard.py` — 16 tests. + +Frontend +- `packages/contracts/src/index.ts` — `CatalogPayload`, `CatalogLanguage/Framework/Defaults`, `WizardCompleteResult`, `AppProfile`, `DeployTargetSummary`, and request bodies (`SetupDraft`, `CheckGitBody`, `CheckDeployBody`, `WizardCompleteBody`, `DeployTargetPutBody`, `GitCredentialsPutBody`, `AppProfileUpdate`). +- `apps/web/src/lib/api-org.ts` — `setupApi` (wizard) + `appProfileApi` (org-scoped profile/deploy/git). +- `apps/web/src/app/onboarding/page.tsx` — rebuilt as a 5-step wizard (stepper, back/next, draft auto-save + resume, secrets stripped from the draft). +- `apps/web/src/app/(app)/settings/page.tsx` — new "اپلیکیشن" tab (`AppTab`): profile editing, git credential rotation, deploy-target edit + test. +- `apps/web/src/test/org-wizard.test.tsx` — 6 tests (catalog load, slug auto-suggest, step navigation, **secrets never in draft payload**, manager-with-org redirect, plain-user waiting screen). + +## Contract consumption (frozen — unchanged) + +- `RepoWorkspacePort.test_remote` (Agent 3) — external git check. +- `DeployTargetTester.test_connection` (Agent 3) — production deploy check. +- `WorkforceBootstrapPort.bootstrap_team` (Agent 2) — called after commit. +- `DeliveryHooks.on_app_created` (Agent 3) — called after commit; each hook called once, failure logged, never rolls back the org. + +## Manual QA steps + +1. As the owner, invite a manager (Phase 1 flow); log in as that manager → redirected to `/onboarding`. +2. Step through: org name/slug → app type/language/framework (advanced accordion prefilled) → git (platform or own repo, "بررسی اتصال") → optional production deploy ("تست اتصال") → review → create. +3. Success screen → "برو به مدیر AI" → `/manager`. `GET /api/auth/me` now returns `needs_onboarding: false`; exactly one `projects` row exists with a full profile. +4. In the new org, open Settings → "اپلیکیشن": edit commands/port/healthcheck, rotate git credentials, set + test a deploy target. Secrets never appear in responses (only `has_secret` / masked values). +5. Confirm a second `POST /setup/complete` returns **409**. + +## Known limitations / open items + +- No `GET` for the deploy target exists yet (the service `update_deploy_target` only writes). The settings tab therefore shows post-save feedback from the `PUT` response (`has_secret`, `last_test_ok`, `last_test_message`) rather than a pre-filled form. A read endpoint can be added later without touching the contract. +- Default model grants (Phase 3) are **not** copied on `complete` yet — `complete` passes `deploy_target`/git through; grant copy lands in Phase 3.3. +- CHANGE REQUESTs: none blocking this phase. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 75e872e..3527178 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1093,3 +1093,159 @@ export interface WsDeployStatusData { status: ProductionDeployStatus; message: string; } + +/* -- org wizard (v2 A1-2) ------------------------------------------------- */ + +export interface CatalogPayload { + app_types: string[]; + app_type_labels: Record; + git_sources: string[]; + languages: CatalogLanguage[]; +} + +export interface CatalogLanguage { + key: string; + display: string; + frameworks: CatalogFramework[]; +} + +export interface CatalogFramework { + key: string; + display: string; + app_type: string; + defaults: CatalogDefaults; +} + +export interface CatalogDefaults { + runtime_version: string; + package_manager: string; + setup_command: string; + build_command: string | null; + test_command: string | null; + start_command: string; + app_port: number; + healthcheck_path: string; + docker_image: string; +} + +export interface WizardCompleteResult { + organization: { id: string; name: string; slug: string }; + project: { id: string; name: string; slug: string }; + deploy_target_id: string | null; +} + +export interface AppProfile { + project_id: string; + app_type: string | null; + language: string | null; + framework: string | null; + runtime_version: string | null; + package_manager: string | null; + setup_command: string | null; + build_command: string | null; + test_command: string | null; + start_command: string | null; + app_port: number | null; + healthcheck_path: string; + git_source: string; + external_repo_url: string | null; + default_branch: string; + wizard_completed: boolean; +} + +export interface DeployTargetSummary { + id: string; + kind: string; + host: string; + port: number; + username: string; + auth_type: string; + remote_path: string; + has_secret: boolean; + last_test_ok: boolean | null; + last_test_message: string | null; +} + +/* -- org wizard request bodies (v2 A1-2) -------------------------------- */ + +/** Persisted wizard draft. Secrets are never stored here — only in the final submit. */ +export interface SetupDraft { + step: number; + data: Record; +} + +export interface CheckGitBody { + url: string; + auth_type?: string; + username?: string | null; + secret?: string | null; +} + +export interface CheckDeployBody { + kind: string; + host: string; + port?: number; + username: string; + auth_type?: string; + secret?: string | null; + remote_path?: string; +} + +/** Final wizard submission. Mirrors the backend `WizardCompleteRequest`. */ +export interface WizardCompleteBody { + org_name: string; + slug: string; + app_name: string; + app_type: string; + language: string; + framework: string; + runtime_version?: string | null; + package_manager?: string | null; + setup_command?: string | null; + build_command?: string | null; + test_command?: string | null; + start_command?: string | null; + app_port?: number | null; + healthcheck_path?: string; + git_source?: string; + external_repo_url?: string | null; + external_git_auth_type?: string; + external_git_username?: string | null; + external_git_secret?: string | null; + deploy_target_kind?: string | null; + deploy_target_host?: string | null; + deploy_target_port?: number; + deploy_target_username?: string | null; + deploy_target_auth_type?: string; + deploy_target_secret?: string | null; + deploy_target_remote_path?: string; +} + +export type DeployTargetPutBody = CheckDeployBody; + +export interface GitCredentialsPutBody { + auth_type?: string; + username?: string | null; + secret?: string | null; +} + +/** Updatable app-profile fields (org owner/admin only). */ +export type AppProfileUpdate = Partial< + Pick< + AppProfile, + | "app_type" + | "language" + | "framework" + | "runtime_version" + | "package_manager" + | "setup_command" + | "build_command" + | "test_command" + | "start_command" + | "app_port" + | "healthcheck_path" + | "git_source" + | "external_repo_url" + | "default_branch" + > +>;