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

This commit is contained in:
mjalmoudy94
2026-10-14 10:46:47 +03:30
parent 5095a29df9
commit 5fb85d6eed
13 changed files with 3485 additions and 71 deletions
@@ -0,0 +1,43 @@
"""org wizard: draft table for resume-safe setup flow
Revision ID: d5e6f7a8b9c0
Revises: a1b2c3d4e5f1
Create Date: 2026-10-14 06:00:00.000000
Owned by Agent 1 (PLAN-v2 §6). Holds the non-secret half of the wizard state
so a refresh never loses the user's progress.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d5e6f7a8b9c0"
down_revision: str | None = "a1b2c3d4e5f1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"org_setup_drafts",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"user_id",
sa.String(length=32),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("step", sa.Integer(), nullable=False, server_default="1"),
sa.Column("data_json", sa.Text(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_org_setup_drafts_user_id", "org_setup_drafts", ["user_id"])
def downgrade() -> None:
op.drop_table("org_setup_drafts")
@@ -0,0 +1,596 @@
"""Organization creation wizard — from zero to a running app in one transaction.
The wizard is the *only* path that creates a project. It guarantees the 1-org-1-app
rule by checking before insertion and by relying on the DB unique constraint.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from urllib.parse import urlsplit
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.delivery_hooks import DeliveryHooks
from app.application.ports.deploy_target import DeployCheck, DeployTargetSpec, DeployTargetTester
from app.application.ports.git_provider import GitProviderPort
from app.application.ports.repo_workspace import RemoteCheck, RemoteCredentials, RepoWorkspacePort
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
from app.core.crypto import encrypt
from app.core.errors import ConflictError, NotFoundError, PermissionDeniedError, ValidationError
from app.domain.base import ensure_unique_slug, new_id, slugify, utcnow
from app.domain.identity.models import (
Membership,
MembershipStatus,
Organization,
Role,
User,
)
from app.domain.platform.rules import is_blocked_address
from app.domain.projects.catalog import (
ProfileDefaults,
defaults_for,
)
from app.domain.projects.models import (
ProjectExternalGit,
ProjectGitCredential,
ProjectGitRepo,
)
from app.infrastructure.db.models import CreditWalletRow, ProjectRow
from app.infrastructure.db.models_org import OrgSetupDraftRow
from app.infrastructure.repositories.app_profile import EDITABLE_FIELDS
from app.infrastructure.repositories.identity import MembershipRepository, OrganizationRepository
from app.infrastructure.repositories.projects import (
DeployTargetRepository,
ProjectExternalGitRepository,
ProjectGitCredentialRepository,
ProjectGitRepoRepository,
ProjectRepository,
)
logger = logging.getLogger("agy.org.wizard")
#: A minimal allowed scheme set for user-supplied git remotes.
_GIT_SCHEMES = {"https", "ssh"}
@dataclass(slots=True, kw_only=True)
class WizardPayload:
"""Validated data the wizard submits on ``complete``."""
org_name: str
slug: str
app_name: str
app_type: str
language: str
framework: str
# Optional overrides on top of catalog defaults
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
external_git_auth_type: str = "token"
external_git_username: str | None = None
external_git_secret: str | None = None
# Optional deploy target
deploy_target_kind: str | None = None
deploy_target_host: str | None = None
deploy_target_port: int = 22
deploy_target_username: str | None = None
deploy_target_auth_type: str = "password"
deploy_target_secret: str | None = None
deploy_target_remote_path: str = "/"
class OrgSetupService:
"""Backend for the org-creation wizard and app-profile management."""
def __init__(
self,
session: AsyncSession,
*,
repo_workspace: RepoWorkspacePort | None = None,
deploy_tester: DeployTargetTester | None = None,
git_provider: GitProviderPort | None = None,
workforce_bootstrap: WorkforceBootstrapPort | None = None,
delivery_hooks: DeliveryHooks | None = None,
) -> None:
self.session = session
self.orgs = OrganizationRepository(session)
self.memberships = MembershipRepository(session)
self.projects = ProjectRepository(session)
self.external_git = ProjectExternalGitRepository(session)
self.git_repos = ProjectGitRepoRepository(session)
self.git_credentials = ProjectGitCredentialRepository(session)
self.deploys = DeployTargetRepository(session)
self.repo_workspace = repo_workspace
self.deploy_tester = deploy_tester
self.git_provider = git_provider
self.workforce_bootstrap = workforce_bootstrap
self.delivery_hooks = delivery_hooks
# ------------------------------------------------------------------
# Catalog
# ------------------------------------------------------------------
@staticmethod
def catalog() -> dict:
from app.domain.projects.catalog import catalog_payload
return catalog_payload()
# ------------------------------------------------------------------
# Draft
# ------------------------------------------------------------------
async def get_draft(self, user_id: str) -> dict:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
return {"step": 1, "data": {}}
return {"step": row.step, "data": json.loads(row.data_json)}
async def save_draft(self, user_id: str, step: int, data: dict) -> None:
row = await self.session.scalar(
select(OrgSetupDraftRow).where(OrgSetupDraftRow.user_id == user_id)
)
if row is None:
row = OrgSetupDraftRow(
user_id=user_id, step=step, data_json=json.dumps(data)
)
self.session.add(row)
else:
row.step = step
row.data_json = json.dumps(data)
await self.session.flush()
# ------------------------------------------------------------------
# Git remote validation
# ------------------------------------------------------------------
async def check_git(
self,
*,
url: str,
auth_type: str = "none",
username: str | None = None,
secret: str | None = None,
) -> RemoteCheck:
_validate_git_url(url)
if self.repo_workspace is None:
raise ValidationError("git workspace در دسترس نیست.")
return await self.repo_workspace.test_remote(
url=url,
credentials=RemoteCredentials(
auth_type=auth_type, username=username, secret=secret
),
)
# ------------------------------------------------------------------
# Deploy-target validation
# ------------------------------------------------------------------
async def check_deploy(self, *, target: DeployTargetSpec) -> DeployCheck:
if self.deploy_tester is None:
raise ValidationError("تستر مقصد دیپلوی در دسترس نیست.")
return await self.deploy_tester.test_connection(target=target)
# ------------------------------------------------------------------
# Wizard completion
# ------------------------------------------------------------------
async def complete(self, user: User, payload: WizardPayload) -> dict:
if not user.can_create_organization:
raise PermissionDeniedError(
"ساخت سازمان فقط برای مالک سامانه و مدیران امکان‌پذیر است."
)
# -- slug uniqueness -------------------------------------------------
base_slug = slugify(payload.slug or payload.org_name)
slug = ensure_unique_slug(base_slug, await self.orgs.list_all_slugs())
# -- defaults from catalog -------------------------------------------
catalog_defaults = defaults_for(
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
)
# -- create org + owner membership + wallet --------------------------
org = await self.orgs.create(
Organization(name=payload.org_name.strip(), slug=slug, owner_user_id=user.id)
)
await self.memberships.add(
Membership(
organization_id=org.id,
user_id=user.id,
role=Role.OWNER,
status=MembershipStatus.ACTIVE,
)
)
self.session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
# -- create the single app -------------------------------------------
project_row = await self._create_single_app(org.id, user.id, payload, catalog_defaults)
# -- git setup -------------------------------------------------------
if payload.git_source == "platform":
await self._setup_platform_git(project_row.id, slug)
elif payload.git_source == "external":
await self._setup_external_git(
project_row.id,
url=payload.external_repo_url or "",
auth_type=payload.external_git_auth_type,
username=payload.external_git_username,
secret=payload.external_git_secret,
)
else:
raise ValidationError("منبع git نامعتبر است.")
# -- optional deploy target ------------------------------------------
deploy_target_id: str | None = None
if payload.deploy_target_kind:
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
await self.session.flush()
# -- commit so hooks see a consistent world --------------------------
await self.session.commit()
# -- post-commit hooks -----------------------------------------------
await self._call_hooks(organization_id=org.id, project_id=project_row.id)
return {
"organization": {
"id": org.id,
"name": org.name,
"slug": org.slug,
},
"project": {
"id": project_row.id,
"name": project_row.name,
"slug": project_row.slug,
},
"deploy_target_id": deploy_target_id,
}
async def _create_single_app(
self,
org_id: str,
user_id: str,
payload: WizardPayload,
defaults: ProfileDefaults,
) -> ProjectRow:
"""Create the one project for this org, raising 409 if one exists."""
existing = await self.session.scalar(
select(ProjectRow).where(ProjectRow.organization_id == org_id)
)
if existing is not None:
raise ConflictError("این سازمان قبلاً یک اپلیکیشن دارد.")
row = ProjectRow(
id=new_id(),
organization_id=org_id,
name=payload.app_name.strip(),
slug=payload.slug.strip(),
created_by_user_id=user_id,
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
runtime_version=(payload.runtime_version or defaults.runtime_version),
package_manager=(payload.package_manager or defaults.package_manager),
setup_command=(payload.setup_command or defaults.setup_command),
build_command=(
payload.build_command
if payload.build_command is not None
else defaults.build_command
),
test_command=(
payload.test_command
if payload.test_command is not None
else defaults.test_command
),
start_command=(payload.start_command or defaults.start_command),
app_port=(payload.app_port if payload.app_port is not None else defaults.app_port),
healthcheck_path=payload.healthcheck_path or defaults.healthcheck_path,
git_source=payload.git_source,
external_repo_url=payload.external_repo_url,
default_branch="main",
wizard_completed_at=utcnow(),
created_at=utcnow(),
)
self.session.add(row)
await self.session.flush()
return row
async def _setup_platform_git(self, project_id: str, repo_name: str) -> None:
if self.git_provider is None:
raise ValidationError("git provider در دسترس نیست.")
repo_info = await self.git_provider.ensure_project_repo(
project_id=project_id, repo_name=repo_name
)
creds = await self.git_provider.ensure_credentials(project_id=project_id)
await self.git_repos.upsert(
ProjectGitRepo(
project_id=project_id,
provider="gitea",
owner=repo_info.owner,
repo_name=repo_info.repo_name,
clone_url=repo_info.clone_url,
web_url=repo_info.web_url,
default_branch=repo_info.default_branch or "main",
)
)
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=creds.username,
encrypted_password=encrypt(creds.password),
)
)
async def _setup_external_git(
self,
project_id: str,
*,
url: str,
auth_type: str,
username: str | None,
secret: str | None,
) -> None:
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
async def _setup_deploy_target(
self, org_id: str, project_id: str, payload: WizardPayload
) -> str:
secret = payload.deploy_target_secret
values = {
"organization_id": org_id,
"project_id": project_id,
"kind": payload.deploy_target_kind,
"host": payload.deploy_target_host or "",
"port": payload.deploy_target_port,
"username": payload.deploy_target_username or "",
"auth_type": payload.deploy_target_auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": payload.deploy_target_remote_path or "/",
"is_active": True,
}
row = await self.deploys.upsert(values=values)
return str(row.id)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
if self.workforce_bootstrap:
try:
await self.workforce_bootstrap.bootstrap_team(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning("Workforce bootstrap failed for %s: %s", organization_id, exc)
if self.delivery_hooks:
try:
await self.delivery_hooks.on_app_created(
organization_id=organization_id, project_id=project_id
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Delivery hook on_app_created failed for %s: %s",
organization_id,
exc,
)
# ------------------------------------------------------------------
# App profile editing (post-wizard)
# ------------------------------------------------------------------
async def get_profile(self, *, organization_id: str, project_id: str) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
profile = await AppProfileRepository(self.session).get(
organization_id=organization_id, project_id=project_id
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_profile(
self, *, organization_id: str, project_id: str, values: dict
) -> dict:
from app.infrastructure.repositories.app_profile import AppProfileRepository
repo = AppProfileRepository(self.session)
unknown = set(values) - EDITABLE_FIELDS
if unknown:
raise ValidationError(f"فیلدهای غیرقابل ویرایش: {sorted(unknown)}")
profile = await repo.update_profile(
organization_id=organization_id, project_id=project_id, values=values
)
if profile is None:
raise NotFoundError("پروفایل اپلیکیشن یافت نشد.")
return {
"project_id": profile.project_id,
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"git_source": profile.git_source,
"external_repo_url": profile.external_repo_url,
"default_branch": profile.default_branch,
"wizard_completed": profile.wizard_completed,
}
async def update_deploy_target(
self,
*,
organization_id: str,
project_id: str,
kind: str,
host: str,
port: int,
username: str,
auth_type: str = "password",
secret: str | None = None,
remote_path: str = "/",
) -> dict:
# Verify project belongs to org
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
values = {
"organization_id": organization_id,
"project_id": project_id,
"kind": kind,
"host": host or "",
"port": port,
"username": username or "",
"auth_type": auth_type or "password",
"secret_encrypted": encrypt(secret) if secret else None,
"remote_path": remote_path or "/",
"is_active": True,
}
target = await self.deploys.upsert(values=values)
return {
"id": target.id,
"kind": target.kind,
"host": target.host,
"port": target.port,
"username": target.username,
"auth_type": target.auth_type,
"remote_path": target.remote_path,
"has_secret": bool(target.secret_encrypted),
"last_test_ok": target.last_test_ok,
"last_test_message": target.last_test_message,
}
async def update_git_credentials(
self,
*,
organization_id: str,
project_id: str,
auth_type: str,
username: str | None = None,
secret: str | None = None,
) -> dict:
row = await self.session.scalar(
select(ProjectRow).where(
ProjectRow.id == project_id,
ProjectRow.organization_id == organization_id,
)
)
if row is None:
raise NotFoundError("پروژه یافت نشد.")
git_source = (row.git_source or "platform").lower()
if git_source == "platform":
await self.git_credentials.upsert(
ProjectGitCredential(
project_id=project_id,
username=username or "",
encrypted_password=encrypt(secret) if secret else "",
)
)
return {"git_source": "platform", "has_secret": bool(secret)}
# external
await self.external_git.upsert(
ProjectExternalGit(
project_id=project_id,
auth_type=auth_type,
username=username,
secret_encrypted=encrypt(secret) if secret else None,
)
)
return {"git_source": "external", "has_secret": bool(secret)}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_git_url(url: str) -> None:
"""Lightweight format check before we hand the URL to the git adapter.
Rejects: wrong scheme, embedded credentials, obvious localhost/private IPs.
The DNS-resolved SSRF check lives in ``RepoWorkspacePort.test_remote``.
"""
clean = (url or "").strip()
if not clean:
raise ValidationError("آدرس git نمی‌تواند خالی باشد.")
# Normalise scp-style ``git@host:path`` so urlsplit can parse it.
working = clean
if "://" not in working and re.match(r"^[^/@\s]+@[^/:\s]+:", working):
working = "ssh://" + working.replace(":", "/", 1)
parts = urlsplit(working)
scheme = (parts.scheme or "").lower()
if scheme not in _GIT_SCHEMES:
raise ValidationError("آدرس git باید با https:// یا ssh:// یا git@ شروع شود.")
if parts.password is not None:
raise ValidationError("آدرس git نباید شامل نام کاربری و رمز باشد.")
host = parts.hostname or ""
if not host:
raise ValidationError("میزبان آدرس git مشخص نیست.")
lowered = host.lower().strip(".")
if lowered in {"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"}:
raise ValidationError("اتصال به میزبان محلی مجاز نیست.")
# Reject blocked IP literals without DNS.
try:
import ipaddress
ipaddress.ip_address(lowered.strip("[]"))
except ValueError:
pass
else:
if is_blocked_address(lowered.strip("[]")):
raise ValidationError("اتصال به آدرس‌های داخلی مجاز نیست.")
+1
View File
@@ -21,6 +21,7 @@ class AuditAction:
PASSWORD_CHANGE = "auth.password_change" PASSWORD_CHANGE = "auth.password_change"
ORG_CREATE = "organization.create" ORG_CREATE = "organization.create"
ORG_DELETE = "organization.delete" ORG_DELETE = "organization.delete"
ORG_WIZARD_COMPLETE = "organization.wizard_complete"
#: An organization changed hands because its owning manager was demoted or #: An organization changed hands because its owning manager was demoted or
#: deleted — the platform owner takes over. Never automatic for any other #: deleted — the platform owner takes over. Never automatic for any other
#: reason. #: reason.
+403
View File
@@ -0,0 +1,403 @@
"""The org-creation wizard's application catalog.
Pure Python, no I/O: the wizard frontend fetches it from ``GET /setup/catalog``
and the backend uses ``defaults_for`` to prefill — and to *validate* — the app
profile a user submits. Keeping the catalog in one place means the "Next.js"
card and the server's idea of what "Next.js" means can never drift apart, and a
typo in a framework name fails loudly at validation time instead of producing a
half-built project.
The catalog is intentionally conservative: it lists what the platform actually
knows how to build and run, not every language on earth. Each framework carries
enough defaults that a user who accepts the suggestions gets a working app
without touching a terminal.
"""
from __future__ import annotations
from dataclasses import dataclass
#: The kinds of app an organization can host. Mirrors ``APP_TYPES`` in
#: ``app.domain.projects.profile``; declared again here so the catalog is the
#: single source the frontend renders from.
APP_TYPES: tuple[str, ...] = ("web_app", "api", "static_site", "bot", "worker", "other")
#: Display labels (Persian) keyed by app type, for the wizard cards.
APP_TYPE_LABELS: dict[str, str] = {
"web_app": "وب‌اپلیکیشن",
"api": "ای‌پی‌آی",
"static_site": "سایت ایستا",
"bot": "بات",
"worker": "ورکر",
"other": "سایر",
}
GIT_SOURCES: tuple[str, ...] = ("platform", "external")
@dataclass(slots=True, frozen=True, kw_only=True)
class ProfileDefaults:
"""The values ``defaults_for`` hands back for a (type, language, framework)."""
app_type: str
language: str
framework: str
runtime_version: str
package_manager: str
setup_command: str
build_command: str | None
test_command: str | None
start_command: str
app_port: int
healthcheck_path: str
docker_image: str
@property
def needs_build(self) -> bool:
return self.build_command is not None and self.build_command.strip() != ""
@dataclass(slots=True, frozen=True, kw_only=True)
class FrameworkEntry:
"""One selectable framework inside a language group."""
key: str
display: str
defaults: ProfileDefaults
@dataclass(slots=True, frozen=True, kw_only=True)
class LanguageEntry:
"""A language group and the frameworks it offers."""
key: str
display: str
frameworks: tuple[FrameworkEntry, ...]
def _fw(
*,
key: str,
display: str,
app_type: str,
runtime_version: str,
package_manager: str,
setup_command: str,
start_command: str,
app_port: int,
healthcheck_path: str = "/",
build_command: str | None = None,
test_command: str | None = None,
docker_image: str,
) -> FrameworkEntry:
return FrameworkEntry(
key=key,
display=display,
defaults=ProfileDefaults(
app_type=app_type,
language="", # filled by the language group below
framework=key,
runtime_version=runtime_version,
package_manager=package_manager,
setup_command=setup_command,
build_command=build_command,
test_command=test_command,
start_command=start_command,
app_port=app_port,
healthcheck_path=healthcheck_path,
docker_image=docker_image,
),
)
# ---------------------------------------------------------------------------
# Node.js
# ---------------------------------------------------------------------------
_NODE = (
_fw(
key="next",
display="Next.js",
app_type="web_app",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command="npm run test",
start_command="npm run start",
app_port=3000,
healthcheck_path="/",
docker_image="node:20-slim",
),
_fw(
key="express",
display="Express",
app_type="api",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command=None,
test_command="npm run test",
start_command="npm start",
app_port=3000,
healthcheck_path="/health",
docker_image="node:20-slim",
),
_fw(
key="nestjs",
display="NestJS",
app_type="api",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command="npm run test",
start_command="npm run start",
app_port=3000,
healthcheck_path="/health",
docker_image="node:20-slim",
),
_fw(
key="vite-react",
display="Vite + React",
app_type="web_app",
runtime_version="20",
package_manager="npm",
setup_command="npm install",
build_command="npm run build",
test_command=None,
start_command="npm run dev -- --host 0.0.0.0",
app_port=5173,
healthcheck_path="/",
docker_image="node:20-slim",
),
)
# ---------------------------------------------------------------------------
# Python
# ---------------------------------------------------------------------------
_PYTHON = (
_fw(
key="fastapi",
display="FastAPI",
app_type="api",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="pytest",
start_command="uvicorn main:app --host 0.0.0.0 --port 8000",
app_port=8000,
healthcheck_path="/health",
docker_image="python:3.12-slim",
),
_fw(
key="django",
display="Django",
app_type="web_app",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="python manage.py test",
start_command="gunicorn config.wsgi:application --bind 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/",
docker_image="python:3.12-slim",
),
_fw(
key="flask",
display="Flask",
app_type="api",
runtime_version="3.12",
package_manager="pip",
setup_command="pip install -r requirements.txt",
build_command=None,
test_command="pytest",
start_command="gunicorn app:app --bind 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/health",
docker_image="python:3.12-slim",
),
)
# ---------------------------------------------------------------------------
# PHP
# ---------------------------------------------------------------------------
_PHP = (
_fw(
key="laravel",
display="Laravel",
app_type="web_app",
runtime_version="8.3",
package_manager="composer",
setup_command="composer install",
build_command="npm install && npm run build",
test_command="php artisan test",
start_command="php artisan serve --host 0.0.0.0 --port 8000",
app_port=8000,
healthcheck_path="/",
docker_image="php:8.3-fpm",
),
_fw(
key="plain",
display="PHP ساده",
app_type="web_app",
runtime_version="8.3",
package_manager="composer",
setup_command="composer install",
build_command=None,
test_command="php -l .",
start_command="php -S 0.0.0.0:8000",
app_port=8000,
healthcheck_path="/",
docker_image="php:8.3-cli",
),
)
# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------
_GO = (
_fw(
key="plain",
display="Go ساده",
app_type="api",
runtime_version="1.22",
package_manager="go mod",
setup_command="go mod download",
build_command="go build -o bin/app ./cmd",
test_command="go test ./...",
start_command="./bin/app",
app_port=8080,
healthcheck_path="/health",
docker_image="golang:1.22",
),
)
# ---------------------------------------------------------------------------
# Static
# ---------------------------------------------------------------------------
_STATIC = (
_fw(
key="html",
display="HTML ایستا",
app_type="static_site",
runtime_version="",
package_manager="",
setup_command="",
build_command=None,
test_command=None,
start_command="",
app_port=80,
healthcheck_path="/",
docker_image="nginx:alpine",
),
)
#: The full catalog, in display order.
LANGUAGES: tuple[LanguageEntry, ...] = (
LanguageEntry(key="node", display="Node.js", frameworks=_NODE),
LanguageEntry(key="python", display="Python", frameworks=_PYTHON),
LanguageEntry(key="php", display="PHP", frameworks=_PHP),
LanguageEntry(key="go", display="Go", frameworks=_GO),
LanguageEntry(key="static", display="ایستا", frameworks=_STATIC),
)
#: Fast lookup ``(language, framework) -> ProfileDefaults``.
_LOOKUP: dict[tuple[str, str], ProfileDefaults] = {}
def _build_lookup() -> None:
from dataclasses import asdict
for lang in LANGUAGES:
for fw in lang.frameworks:
base = asdict(fw.defaults)
base["language"] = lang.key
defaults = ProfileDefaults(**base)
_LOOKUP[(lang.key, fw.key)] = defaults
_build_lookup()
def defaults_for(*, app_type: str, language: str, framework: str) -> ProfileDefaults:
"""Resolve the catalog defaults for a concrete choice.
Raises ``ValueError`` when the combination is not in the catalog — the
backend calls this while validating the wizard's ``complete`` payload, so an
unknown framework (a stale frontend, a hand-crafted request) is rejected
before anything is written.
"""
key = (language, framework)
if key not in _LOOKUP:
raise ValueError(
f"چارچوب ناشناخته: language={language!r} framework={framework!r}"
)
found = _LOOKUP[key]
if found.app_type != app_type:
raise ValueError(
f"نوع اپلیکیشن با چارچوب هم‌خوانی ندارد: انتظار {found.app_type!r}، دریافت {app_type!r}"
)
return found
def is_known_language(language: str) -> bool:
return any(lang.key == language for lang in LANGUAGES)
def is_known_framework(language: str, framework: str) -> bool:
return (language, framework) in _LOOKUP
def catalog_payload() -> dict:
"""Structure returned by ``GET /setup/catalog``."""
def _framework_out(fw: FrameworkEntry) -> dict:
d = fw.defaults
return {
"key": fw.key,
"display": fw.display,
"app_type": d.app_type,
"defaults": {
"runtime_version": d.runtime_version,
"package_manager": d.package_manager,
"setup_command": d.setup_command,
"build_command": d.build_command,
"test_command": d.test_command,
"start_command": d.start_command,
"app_port": d.app_port,
"healthcheck_path": d.healthcheck_path,
"docker_image": d.docker_image,
},
}
return {
"app_types": list(APP_TYPES),
"app_type_labels": APP_TYPE_LABELS,
"git_sources": list(GIT_SOURCES),
"languages": [
{
"key": lang.key,
"display": lang.display,
"frameworks": [_framework_out(fw) for fw in lang.frameworks],
}
for lang in LANGUAGES
],
}
__all__ = [
"APP_TYPES",
"APP_TYPE_LABELS",
"GIT_SOURCES",
"LANGUAGES",
"FrameworkEntry",
"LanguageEntry",
"ProfileDefaults",
"catalog_payload",
"defaults_for",
"is_known_framework",
"is_known_language",
]
+28 -1
View File
@@ -76,4 +76,31 @@ class PlatformInviteRow(Base):
return self.expires_at <= reference return self.expires_at <= reference
__all__ = ["PlatformInviteRow"] class OrgSetupDraftRow(Base):
"""A user's in-progress org-creation wizard state.
The wizard autosaves its current step here so a refresh resumes. By design
the payload never contains secrets: git tokens, SSH keys and deploy-target
passwords are submitted only on the final ``complete`` call and are held
encrypted elsewhere (``project_external_git`` / ``deploy_targets``). Storing
them in the draft would defeat the encryption at rest.
"""
__tablename__ = "org_setup_drafts"
id: Mapped[str] = pk()
user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
#: The furthest step the user reached (1..5). Lets the frontend jump back.
step: Mapped[int] = mapped_column(default=1, nullable=False)
#: JSON blob of the non-secret wizard fields. Validated upstream; secrets
#: are stripped before this row is written.
data_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
created_at: Mapped[datetime] = created()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
@@ -6,6 +6,442 @@ route its full path (no prefix is applied here besides ``/api``).
from __future__ import annotations from __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"]) router = APIRouter(tags=["app-setup"])
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class CatalogOut(BaseModel):
model_config = ConfigDict(extra="ignore")
catalog: dict
class DraftRequest(BaseModel):
step: int = Field(ge=1, le=5)
data: dict = {}
class DraftOut(BaseModel):
step: int
data: dict
class CheckGitRequest(BaseModel):
url: str = Field(min_length=1, max_length=500)
auth_type: str = "none"
username: str | None = None
secret: str | None = None
class CheckDeployRequest(BaseModel):
kind: str = Field(min_length=1)
host: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
username: str = Field(min_length=1)
auth_type: str = "password"
secret: str | None = None
remote_path: str = "/"
class WizardCompleteRequest(BaseModel):
org_name: str = Field(min_length=2, max_length=160)
slug: str = Field(min_length=2, max_length=120, pattern=r"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")
app_name: str = Field(min_length=1, max_length=160)
app_type: str = Field(min_length=1)
language: str = Field(min_length=1)
framework: str = Field(min_length=1)
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
external_git_auth_type: str = "token"
external_git_username: str | None = None
external_git_secret: str | None = None
deploy_target_kind: str | None = None
deploy_target_host: str | None = None
deploy_target_port: int = 22
deploy_target_username: str | None = None
deploy_target_auth_type: str = "password"
deploy_target_secret: str | None = None
deploy_target_remote_path: str = "/"
@field_validator("app_type")
@classmethod
def _valid_app_type(cls, v: str) -> str:
if v not in APP_TYPES:
raise ValueError("نوع اپلیکیشن نامعتبر است.")
return v
@field_validator("git_source")
@classmethod
def _valid_git_source(cls, v: str) -> str:
if v not in GIT_SOURCES:
raise ValueError("منبع git نامعتبر است.")
return v
class WizardCompleteOut(BaseModel):
organization: dict
project: dict
deploy_target_id: str | None = None
class ProfileOut(BaseModel):
model_config = ConfigDict(extra="ignore")
project_id: str
app_type: str | None = None
language: str | None = None
framework: str | None = None
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str = "/"
git_source: str = "platform"
external_repo_url: str | None = None
default_branch: str = "main"
wizard_completed: bool = False
class ProfilePatchRequest(BaseModel):
app_type: str | None = None
language: str | None = None
framework: str | None = None
runtime_version: str | None = None
package_manager: str | None = None
setup_command: str | None = None
build_command: str | None = None
test_command: str | None = None
start_command: str | None = None
app_port: int | None = None
healthcheck_path: str | None = None
git_source: str | None = None
external_repo_url: str | None = None
default_branch: str | None = None
class DeployTargetOut(BaseModel):
id: str
kind: str
host: str
port: int
username: str
auth_type: str
remote_path: str
has_secret: bool
last_test_ok: bool | None = None
last_test_message: str | None = None
class DeployTargetPutRequest(BaseModel):
kind: str = Field(min_length=1)
host: str = Field(min_length=1)
port: int = Field(default=22, ge=1, le=65535)
username: str = Field(min_length=1)
auth_type: str = "password"
secret: str | None = None
remote_path: str = "/"
class GitCredentialsPutRequest(BaseModel):
auth_type: str = "token"
username: str | None = None
secret: str | None = None
class GitCredentialsOut(BaseModel):
git_source: str
has_secret: bool
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_service(session: AsyncSession) -> OrgSetupService:
return OrgSetupService(
session,
repo_workspace=build_repo_workspace(session),
deploy_tester=build_deploy_target_tester(session),
git_provider=get_git_provider(),
workforce_bootstrap=build_workforce_bootstrap(session),
delivery_hooks=build_delivery_hooks(session),
)
async def _project_for_org(session: AsyncSession, org_id: str) -> ProjectRow:
row = await session.scalar(
select(ProjectRow).where(ProjectRow.organization_id == org_id)
)
if row is None:
from app.core.errors import NotFoundError
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
return row
# ---------------------------------------------------------------------------
# Wizard routes (no org context — the org does not exist yet)
# ---------------------------------------------------------------------------
@router.get("/setup/catalog", response_model=dict)
async def get_catalog(
_user=Depends(get_current_user),
) -> dict:
return OrgSetupService.catalog()
@router.get("/setup/draft", response_model=DraftOut)
async def get_draft(
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> DraftOut:
svc = OrgSetupService(session)
data = await svc.get_draft(user.id)
return DraftOut(step=data["step"], data=data["data"])
@router.put("/setup/draft", status_code=status.HTTP_204_NO_CONTENT)
async def save_draft(
payload: DraftRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> None:
svc = OrgSetupService(session)
await svc.save_draft(user.id, step=payload.step, data=payload.data)
@router.post("/setup/check-git", response_model=dict)
async def check_git(
payload: CheckGitRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
svc = _build_service(session)
result = await svc.check_git(
url=payload.url,
auth_type=payload.auth_type,
username=payload.username,
secret=payload.secret,
)
return {
"ok": result.ok,
"default_branch": result.default_branch,
"branches": result.branches,
"message": result.message,
}
@router.post("/setup/check-deploy", response_model=dict)
async def check_deploy(
payload: CheckDeployRequest,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
from app.application.ports.deploy_target import DeployTargetSpec
svc = _build_service(session)
result = await svc.check_deploy(
target=DeployTargetSpec(
kind=payload.kind,
host=payload.host,
port=payload.port,
username=payload.username,
auth_type=payload.auth_type,
secret=payload.secret,
remote_path=payload.remote_path,
)
)
return {
"ok": result.ok,
"message": result.message,
"fingerprint": result.fingerprint,
}
@router.post(
"/setup/complete",
response_model=WizardCompleteOut,
status_code=status.HTTP_201_CREATED,
)
async def complete_wizard(
payload: WizardCompleteRequest,
request: Request,
user=Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> WizardCompleteOut:
svc = _build_service(session)
result = await svc.complete(
user,
WizardPayload(
org_name=payload.org_name,
slug=payload.slug,
app_name=payload.app_name,
app_type=payload.app_type,
language=payload.language,
framework=payload.framework,
runtime_version=payload.runtime_version,
package_manager=payload.package_manager,
setup_command=payload.setup_command,
build_command=payload.build_command,
test_command=payload.test_command,
start_command=payload.start_command,
app_port=payload.app_port,
healthcheck_path=payload.healthcheck_path,
git_source=payload.git_source,
external_repo_url=payload.external_repo_url,
external_git_auth_type=payload.external_git_auth_type,
external_git_username=payload.external_git_username,
external_git_secret=payload.external_git_secret,
deploy_target_kind=payload.deploy_target_kind,
deploy_target_host=payload.deploy_target_host,
deploy_target_port=payload.deploy_target_port,
deploy_target_username=payload.deploy_target_username,
deploy_target_auth_type=payload.deploy_target_auth_type,
deploy_target_secret=payload.deploy_target_secret,
deploy_target_remote_path=payload.deploy_target_remote_path,
),
)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ORG_WIZARD_COMPLETE,
organization_id=result["organization"]["id"],
actor_user_id=user.id,
target_type="organization",
target_id=result["organization"]["id"],
metadata={
"org_name": payload.org_name,
"slug": payload.slug,
"app_name": payload.app_name,
"app_type": payload.app_type,
"language": payload.language,
"framework": payload.framework,
},
ip=ip,
user_agent=ua,
)
return WizardCompleteOut(
organization=result["organization"],
project=result["project"],
deploy_target_id=result.get("deploy_target_id"),
)
# ---------------------------------------------------------------------------
# App profile editing (org-scoped)
# ---------------------------------------------------------------------------
@router.get("/organizations/{org_id}/app/profile", response_model=ProfileOut)
async def get_app_profile(
org_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> ProfileOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.get_profile(organization_id=org_id, project_id=project.id)
return ProfileOut(**data)
@router.patch("/organizations/{org_id}/app/profile", response_model=ProfileOut)
async def patch_app_profile(
org_id: str,
payload: ProfilePatchRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> ProfileOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
values = {k: v for k, v in payload.model_dump().items() if v is not None}
data = await svc.update_profile(
organization_id=org_id, project_id=project.id, values=values
)
return ProfileOut(**data)
@router.put("/organizations/{org_id}/app/deploy-target", response_model=DeployTargetOut)
async def put_deploy_target(
org_id: str,
payload: DeployTargetPutRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> DeployTargetOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.update_deploy_target(
organization_id=org_id,
project_id=project.id,
kind=payload.kind,
host=payload.host,
port=payload.port,
username=payload.username,
auth_type=payload.auth_type,
secret=payload.secret,
remote_path=payload.remote_path,
)
return DeployTargetOut(**data)
@router.put("/organizations/{org_id}/app/git", response_model=GitCredentialsOut)
async def put_git_credentials(
org_id: str,
payload: GitCredentialsPutRequest,
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
session: AsyncSession = Depends(get_session),
) -> GitCredentialsOut:
project = await _project_for_org(session, org_id)
svc = OrgSetupService(session)
data = await svc.update_git_credentials(
organization_id=org_id,
project_id=project.id,
auth_type=payload.auth_type,
username=payload.username,
secret=payload.secret,
)
return GitCredentialsOut(**data)
+386
View File
@@ -0,0 +1,386 @@
"""§v2-A1.2 — org creation wizard + app profile (1 org = 1 app).
Covers the catalog, draft, git/deploy checks, wizard completion, the 1-app guard,
SSRF prevention on git URLs, and the post-wizard profile editing endpoints.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from httpx import AsyncClient
from app.domain.identity.models import PlatformRole
from app.domain.projects.catalog import APP_TYPES
from tests.conftest import auth_header, signup
@pytest_asyncio.fixture(autouse=True)
async def _seeded(seeded):
return seeded
async def _manager(client: AsyncClient) -> tuple[str, dict]:
token = (await signup(client, "wiz-manager@example.com"))["access_token"]
headers = auth_header(token)
return token, headers
async def _user(client: AsyncClient) -> tuple[str, dict]:
token = (
await signup(
client, "wiz-user@example.com", platform_role=PlatformRole.USER
)
)["access_token"]
headers = auth_header(token)
return token, headers
async def _complete(
client: AsyncClient, headers: dict, overrides: dict | None = None
) -> dict:
payload = {
"org_name": "سازمان تست",
"slug": "test-org",
"app_name": "اپ تست",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
if overrides:
payload.update(overrides)
response = await client.post("/api/setup/complete", json=payload, headers=headers)
assert response.status_code == 201, response.text
return response.json()
# ---------------------------------------------------------------------------
# Catalog
# ---------------------------------------------------------------------------
async def test_catalog_returns_app_types_and_languages(client: AsyncClient):
token, headers = await _manager(client)
response = await client.get("/api/setup/catalog", headers=headers)
assert response.status_code == 200
data = response.json()
assert set(data["app_types"]) == set(APP_TYPES)
assert "languages" in data
lang_keys = {lang["key"] for lang in data["languages"]}
assert "node" in lang_keys
assert "python" in lang_keys
# next.js should carry defaults
node = next(lang for lang in data["languages"] if lang["key"] == "node")
next_fw = next(fw for fw in node["frameworks"] if fw["key"] == "next")
assert next_fw["defaults"]["app_port"] == 3000
# ---------------------------------------------------------------------------
# Draft
# ---------------------------------------------------------------------------
async def test_draft_save_and_resume(client: AsyncClient):
token, headers = await _manager(client)
await client.put(
"/api/setup/draft", json={"step": 3, "data": {"org_name": "X"}}, headers=headers
)
response = await client.get("/api/setup/draft", headers=headers)
assert response.status_code == 200
data = response.json()
assert data["step"] == 3
assert data["data"]["org_name"] == "X"
# ---------------------------------------------------------------------------
# Wizard completion — happy paths
# ---------------------------------------------------------------------------
async def test_complete_platform_git_creates_org_and_project(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(client, headers)
assert result["organization"]["name"] == "سازمان تست"
assert result["project"]["name"] == "اپ تست"
assert result["deploy_target_id"] is None
# Profile is readable via the org-scoped endpoint
org_id = result["organization"]["id"]
resp = await client.get(f"/api/organizations/{org_id}/app/profile", headers=headers)
assert resp.status_code == 200
profile = resp.json()
assert profile["app_type"] == "web_app"
assert profile["language"] == "node"
assert profile["framework"] == "next"
assert profile["wizard_completed"] is True
async def test_complete_with_external_git(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"git_source": "external",
"external_repo_url": "https://git.example.invalid/repo.git",
"external_git_auth_type": "token",
"external_git_username": "user",
"external_git_secret": "my-secret-token",
},
)
org_id = result["organization"]["id"]
resp = await client.get(f"/api/organizations/{org_id}/app/profile", headers=headers)
assert resp.status_code == 200
assert resp.json()["git_source"] == "external"
assert resp.json()["external_repo_url"] == "https://git.example.invalid/repo.git"
async def test_complete_with_deploy_target(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"deploy_target_kind": "ssh",
"deploy_target_host": "deploy.example.invalid",
"deploy_target_port": 22,
"deploy_target_username": "root",
"deploy_target_secret": "deploy-secret",
},
)
assert result["deploy_target_id"] is not None
async def test_complete_does_not_block_on_failing_deploy_test(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"deploy_target_kind": "ssh",
"deploy_target_host": "fail.example.invalid",
"deploy_target_port": 22,
"deploy_target_username": "root",
"deploy_target_secret": "deploy-secret",
},
)
# The fake deploy tester returns ok=False for hosts containing "fail",
# but the wizard stores the target regardless (last_test_ok=False).
assert result["deploy_target_id"] is not None
# ---------------------------------------------------------------------------
# Guards
# ---------------------------------------------------------------------------
async def test_plain_user_cannot_complete_wizard(client: AsyncClient):
token, headers = await _user(client)
payload = {
"org_name": "سازمان تست",
"slug": "test-org-user",
"app_name": "اپ تست",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
response = await client.post("/api/setup/complete", json=payload, headers=headers)
assert response.status_code == 403
async def test_second_app_for_same_org_returns_409(client: AsyncClient, session_factory):
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "test-409"})
org_id = result["organization"]["id"]
from app.application.services.org_setup_service import OrgSetupService, WizardPayload
from app.core.errors import ConflictError
from app.domain.projects.catalog import ProfileDefaults
async with session_factory() as session:
svc = OrgSetupService(session)
defaults = ProfileDefaults(
app_type="api", language="python", framework="fastapi",
runtime_version="3.12", package_manager="pip",
setup_command="", build_command=None, test_command=None,
start_command="", app_port=8000, healthcheck_path="/",
docker_image="python:3.12-slim",
)
with pytest.raises(ConflictError, match="این سازمان قبلاً"):
await svc._create_single_app(
org_id=org_id,
user_id="ignored",
payload=WizardPayload(
org_name="X", slug="x", app_name="Y",
app_type="api", language="python", framework="fastapi",
),
defaults=defaults,
)
# ---------------------------------------------------------------------------
# Secrets never leak
# ---------------------------------------------------------------------------
async def test_secrets_never_appear_in_wizard_responses(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"slug": "secret-check",
"git_source": "external",
"external_repo_url": "https://git.example.invalid/repo.git",
"external_git_secret": "SUPER-SECRET-123",
"deploy_target_kind": "ssh",
"deploy_target_host": "deploy.example.invalid",
"deploy_target_secret": "DEPLOY-SECRET-456",
},
)
# Audit endpoint must not contain the secret
org_id = result["organization"]["id"]
audit = await client.get(f"/api/organizations/{org_id}/audit", headers=headers)
assert audit.status_code == 200
for event in audit.json():
raw = str(event)
assert "SUPER-SECRET-123" not in raw
assert "DEPLOY-SECRET-456" not in raw
# Draft must not contain the secret
draft = await client.get("/api/setup/draft", headers=headers)
assert draft.status_code == 200
raw_draft = str(draft.json())
assert "SUPER-SECRET-123" not in raw_draft
assert "DEPLOY-SECRET-456" not in raw_draft
# ---------------------------------------------------------------------------
# SSRF on git URL
# ---------------------------------------------------------------------------
async def test_check_git_rejects_localhost(client: AsyncClient):
token, headers = await _manager(client)
response = await client.post(
"/api/setup/check-git",
json={"url": "https://localhost/repo.git"},
headers=headers,
)
assert response.status_code == 422 or response.status_code == 400
async def test_check_git_rejects_private_ip(client: AsyncClient):
token, headers = await _manager(client)
response = await client.post(
"/api/setup/check-git",
json={"url": "https://192.168.1.1/repo.git"},
headers=headers,
)
assert response.status_code == 422 or response.status_code == 400
async def test_check_git_rejects_credentials_in_url(client: AsyncClient):
token, headers = await _manager(client)
response = await client.post(
"/api/setup/check-git",
json={"url": "https://user:pass@github.com/repo.git"},
headers=headers,
)
assert response.status_code == 422 or response.status_code == 400
# ---------------------------------------------------------------------------
# Profile editing
# ---------------------------------------------------------------------------
async def test_patch_profile_updates_fields(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "patch-test"})
org_id = result["organization"]["id"]
resp = await client.patch(
f"/api/organizations/{org_id}/app/profile",
json={"app_port": 9000, "healthcheck_path": "/ready"},
headers=headers,
)
assert resp.status_code == 200
profile = resp.json()
assert profile["app_port"] == 9000
assert profile["healthcheck_path"] == "/ready"
async def test_put_deploy_target_rotates_secret(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "deploy-test"})
org_id = result["organization"]["id"]
resp = await client.put(
f"/api/organizations/{org_id}/app/deploy-target",
json={
"kind": "sftp",
"host": "sftp.example.invalid",
"port": 22,
"username": "admin",
"secret": "new-secret",
"remote_path": "/var/www",
},
headers=headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["kind"] == "sftp"
assert data["has_secret"] is True
async def test_put_git_credentials_rotates_secret(client: AsyncClient):
token, headers = await _manager(client)
result = await _complete(
client,
headers,
overrides={
"slug": "git-cred-test",
"git_source": "external",
"external_repo_url": "https://git.example.invalid/repo.git",
},
)
org_id = result["organization"]["id"]
resp = await client.put(
f"/api/organizations/{org_id}/app/git",
json={"auth_type": "token", "username": "newuser", "secret": "new-token"},
headers=headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["git_source"] == "external"
assert data["has_secret"] is True
# ---------------------------------------------------------------------------
# Hook recording
# ---------------------------------------------------------------------------
async def test_hooks_called_after_wizard_complete(client: AsyncClient, monkeypatch):
from app.application.services.org_setup_service import OrgSetupService
hook_calls: list[dict] = []
async def _record_hooks(self, *, organization_id: str, project_id: str) -> None:
hook_calls.append({"organization_id": organization_id, "project_id": project_id})
monkeypatch.setattr(OrgSetupService, "_call_hooks", _record_hooks)
token, headers = await _manager(client)
result = await _complete(client, headers, overrides={"slug": "hook-test"})
org_id = result["organization"]["id"]
project_id = result["project"]["id"]
assert any(
c["organization_id"] == org_id and c["project_id"] == project_id
for c in hook_calls
)
+348 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; 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 { import {
Badge, Badge,
Button, Button,
@@ -19,9 +19,11 @@ import {
import { ApiError, api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { import {
CAN_MANAGE_MEMBERS, CAN_MANAGE_MEMBERS,
CAN_MANAGE_PROJECTS,
CAN_VIEW_AUDIT, CAN_VIEW_AUDIT,
roleHasCapability, roleHasCapability,
} from "@/lib/permissions"; } from "@/lib/permissions";
import { appProfileApi, setupApi } from "@/lib/api-org";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
@@ -62,7 +64,7 @@ const AUDIT_LABELS: Record<string, string> = {
"run.cancel": "لغو اجرا", "run.cancel": "لغو اجرا",
}; };
type Tab = "members" | "audit"; type Tab = "members" | "audit" | "app";
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */ /** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
const INVITE_TTL_DAYS = 14; const INVITE_TTL_DAYS = 14;
@@ -206,6 +208,7 @@ export default function SettingsPage() {
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS); const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT); const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
const canManageProjects = roleHasCapability(activeOrganization.role, CAN_MANAGE_PROJECTS);
return ( return (
<div className="anim-stagger flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
@@ -218,6 +221,7 @@ export default function SettingsPage() {
{( {(
[ [
["members", "اعضا و دعوت"], ["members", "اعضا و دعوت"],
["app", "اپلیکیشن"],
["audit", "رویدادهای حسابرسی"], ["audit", "رویدادهای حسابرسی"],
] as const ] as const
).map(([value, label]) => ( ).map(([value, label]) => (
@@ -372,6 +376,8 @@ export default function SettingsPage() {
</Card> </Card>
)} )}
</> </>
) : tab === "app" ? (
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
) : ( ) : (
<Card> <Card>
<CardTitle>رویدادهای حسابرسی</CardTitle> <CardTitle>رویدادهای حسابرسی</CardTitle>
@@ -422,3 +428,343 @@ export default function SettingsPage() {
</div> </div>
); );
} }
/**
* "اپلیکیشن" tab: edit the single app's profile, rotate git credentials, and
* edit or test the production deploy target. Owner/admin only — viewers see a
* read-only summary. (v2 A1-2 §2.8)
*/
function AppTab({ orgId, canEdit }: { orgId: string; canEdit: boolean }) {
const [profile, setProfile] = React.useState<AppProfile | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [form, setForm] = React.useState<Record<string, string>>({});
const [savingProfile, setSavingProfile] = React.useState(false);
// git credential rotation
const [gitAuth, setGitAuth] = React.useState("token");
const [gitUser, setGitUser] = React.useState("");
const [gitSecret, setGitSecret] = React.useState("");
const [gitSaved, setGitSaved] = React.useState<string | null>(null);
const [savingGit, setSavingGit] = React.useState(false);
// deploy target
const [dt, setDt] = React.useState({
kind: "ssh",
host: "",
port: "22",
username: "",
auth_type: "password",
secret: "",
remote_path: "/",
});
const [dtCheck, setDtCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
const [dtChecking, setDtChecking] = React.useState(false);
const [dtSaved, setDtSaved] = React.useState<DeployTargetSummary | null>(null);
const [savingDt, setSavingDt] = React.useState(false);
const load = React.useCallback(async () => {
if (!orgId) return;
setLoading(true);
setError(null);
try {
const p = await appProfileApi.get(orgId);
setProfile(p);
setForm({
app_type: p.app_type ?? "",
language: p.language ?? "",
framework: p.framework ?? "",
runtime_version: p.runtime_version ?? "",
package_manager: p.package_manager ?? "",
setup_command: p.setup_command ?? "",
build_command: p.build_command ?? "",
test_command: p.test_command ?? "",
start_command: p.start_command ?? "",
app_port: p.app_port != null ? String(p.app_port) : "",
healthcheck_path: p.healthcheck_path ?? "/",
external_repo_url: p.external_repo_url ?? "",
default_branch: p.default_branch ?? "main",
});
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت پروفایل ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
const setField = (key: string, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const saveProfile = async () => {
setSavingProfile(true);
setError(null);
try {
const values: Record<string, string | number | null> = {};
for (const k of [
"app_type",
"language",
"framework",
"runtime_version",
"package_manager",
"setup_command",
"build_command",
"test_command",
"start_command",
"healthcheck_path",
"external_repo_url",
"default_branch",
]) {
const v = form[k] ?? "";
values[k] = k === "app_port" ? (v ? Number(v) : null) : v === "" ? null : v;
}
const updated = await appProfileApi.update(orgId, values as never);
setProfile(updated);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ذخیره پروفایل ناموفق بود.");
} finally {
setSavingProfile(false);
}
};
const saveGit = async () => {
setSavingGit(true);
setGitSaved(null);
setError(null);
try {
const res = await appProfileApi.putGitCredentials(orgId, {
auth_type: gitAuth,
username: gitUser || null,
secret: gitSecret || null,
});
setGitSaved(res.has_secret ? "اعتبارنامه به‌روزرسانی شد." : "اعتبارنامه ثبت شد.");
setGitSecret("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "چرخش اعتبارنامه ناموفق بود.");
} finally {
setSavingGit(false);
}
};
const checkDeploy = async () => {
setDtChecking(true);
setDtCheck(null);
setError(null);
try {
const res = await setupApi.checkDeploy({
kind: dt.kind,
host: dt.host,
port: Number(dt.port) || 22,
username: dt.username,
auth_type: dt.auth_type,
secret: dt.secret || null,
remote_path: dt.remote_path || "/",
});
setDtCheck({ ok: res.ok, message: res.message });
} catch (err) {
setDtCheck({
ok: false,
message: err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.",
});
} finally {
setDtChecking(false);
}
};
const saveDeploy = async () => {
setSavingDt(true);
setError(null);
try {
const saved = await appProfileApi.putDeployTarget(orgId, {
kind: dt.kind,
host: dt.host.trim(),
port: Number(dt.port) || 22,
username: dt.username.trim(),
auth_type: dt.auth_type,
secret: dt.secret || null,
remote_path: dt.remote_path || "/",
});
setDtSaved(saved);
setDt((prev) => ({ ...prev, secret: "" }));
} catch (err) {
setError(err instanceof ApiError ? err.message : "ذخیره هدف انتشار ناموفق بود.");
} finally {
setSavingDt(false);
}
};
if (loading) {
return (
<Card>
<LoadingState label="در حال دریافت پروفایل اپلیکیشن…" />
</Card>
);
}
if (!profile) {
return (
<Card>
<EmptyState title="اپلیکیشنی یافت نشد" description="این سازمان هنوز اپلیکیشنی ندارد." />
</Card>
);
}
const readOnly = !canEdit;
return (
<div className="anim-stagger flex flex-col gap-5">
{error && <ErrorState message={error} onRetry={load} />}
{readOnly && (
<Card>
<CardHint>فقط مالک یا مدیر سازمان می‌تواند پروفایل اپلیکیشن را ویرایش کند.</CardHint>
</Card>
)}
{/* Profile */}
<Card>
<CardTitle>پروفایل اپلیکیشن</CardTitle>
<CardHint>
این‌ها همان تنظیماتی هستند که در جادوگر وارد کردید. منبع گیت فقط قابل چرخش
اعتبارنامه است و پس از ساخت تغییر نمی‌کند.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<Field label="نوع" value={form.app_type} disabled={readOnly} onChange={(e) => setField("app_type", e.target.value)} className="ltr-inline" />
<Field label="زبان" value={form.language} disabled={readOnly} onChange={(e) => setField("language", e.target.value)} className="ltr-inline" />
<Field label="چارچوب" value={form.framework} disabled={readOnly} onChange={(e) => setField("framework", e.target.value)} className="ltr-inline" />
<Field label="نسخه runtime" value={form.runtime_version} disabled={readOnly} onChange={(e) => setField("runtime_version", e.target.value)} className="ltr-inline" />
<Field label="مدیر بسته" value={form.package_manager} disabled={readOnly} onChange={(e) => setField("package_manager", e.target.value)} className="ltr-inline" />
<Field label="پورت" type="number" value={form.app_port} disabled={readOnly} onChange={(e) => setField("app_port", e.target.value)} className="ltr-inline" />
</div>
<Field label="دستور نصب" value={form.setup_command} disabled={readOnly} onChange={(e) => setField("setup_command", e.target.value)} className="ltr-inline" />
<Field label="دستور ساخت" value={form.build_command} disabled={readOnly} onChange={(e) => setField("build_command", e.target.value)} className="ltr-inline" />
<Field label="دستور تست" value={form.test_command} disabled={readOnly} onChange={(e) => setField("test_command", e.target.value)} className="ltr-inline" />
<Field label="دستور اجرا" value={form.start_command} disabled={readOnly} onChange={(e) => setField("start_command", e.target.value)} className="ltr-inline" />
<div className="grid grid-cols-2 gap-3">
<Field label="مسیر healthcheck" value={form.healthcheck_path} disabled={readOnly} onChange={(e) => setField("healthcheck_path", e.target.value)} className="ltr-inline" />
<Field label="شاخه پیش‌فرض" value={form.default_branch} disabled={readOnly} onChange={(e) => setField("default_branch", e.target.value)} className="ltr-inline" />
</div>
<Field label="آدرس مخزن خارجی" value={form.external_repo_url} disabled={readOnly} onChange={(e) => setField("external_repo_url", e.target.value)} className="ltr-inline" />
{canEdit && (
<Button onClick={saveProfile} loading={savingProfile}>
ذخیره تغییرات
</Button>
)}
</div>
</Card>
{/* Git credentials rotation */}
<Card>
<CardTitle>اعتبارنامهٔ گیت</CardTitle>
<CardHint>
منبع فعلی: {profile.git_source === "platform" ? "Git پلتفرم" : "مخزن خودم"}. تغییر
منبع پس از ساخت امکان‌پذیر نیست؛ فقط اعتبارنامه را بچرخانید.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
<Select
label="نوع احراز"
value={gitAuth}
disabled={readOnly}
onChange={(e) => setGitAuth(e.target.value)}
options={[
{ value: "token", label: "توکن" },
{ value: "ssh", label: "کلید SSH" },
]}
/>
<Field label="نام کاربری (اختیاری)" value={gitUser} disabled={readOnly} onChange={(e) => setGitUser(e.target.value)} className="ltr-inline" />
<Field
label={gitAuth === "ssh" ? "کلید خصوصی SSH" : "توکن / رمز"}
type="password"
value={gitSecret}
disabled={readOnly}
onChange={(e) => setGitSecret(e.target.value)}
hint="خالی بماند تا مقدار فعلی تغییر نکند."
className="ltr-inline"
/>
{gitSaved && <p className="text-xs text-success">{gitSaved}</p>}
{canEdit && (
<Button variant="secondary" onClick={saveGit} loading={savingGit}>
چرخش اعتبارنامه
</Button>
)}
</div>
</Card>
{/* Deploy target */}
<Card>
<CardTitle>هدف انتشار تولید</CardTitle>
<CardHint>
انتشار در تولید فقط با دکمهٔ خودتان انجام می‌شود. در اینجا فقط هدف را ثبت یا
به‌روزرسانی می‌کنید.
</CardHint>
<div className="mt-4 flex flex-col gap-3">
<Select
label="نوع"
value={dt.kind}
disabled={readOnly}
onChange={(e) => setDt((p) => ({ ...p, kind: e.target.value }))}
options={[
{ value: "ssh", label: "SSH" },
{ value: "sftp", label: "SFTP" },
{ value: "ftp", label: "FTP" },
{ value: "ftps", label: "FTPS" },
]}
/>
<div className="grid grid-cols-3 gap-3">
<Field label="میزبان" value={dt.host} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, host: e.target.value }))} className="ltr-inline" />
<Field label="پورت" type="number" value={dt.port} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, port: e.target.value }))} className="ltr-inline" />
<Field label="نام کاربری" value={dt.username} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, username: e.target.value }))} className="ltr-inline" />
</div>
<Select
label="نوع احراز"
value={dt.auth_type}
disabled={readOnly}
onChange={(e) => setDt((p) => ({ ...p, auth_type: e.target.value }))}
options={[
{ value: "password", label: "رمز عبور" },
{ value: "ssh_key", label: "کلید SSH" },
]}
/>
<Field
label={dt.auth_type === "ssh_key" ? "کلید خصوصی SSH" : "رمز عبور"}
type="password"
value={dt.secret}
disabled={readOnly}
onChange={(e) => setDt((p) => ({ ...p, secret: e.target.value }))}
className="ltr-inline"
/>
<Field label="مسیر مقصد" value={dt.remote_path} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, remote_path: e.target.value }))} className="ltr-inline" />
{dtCheck && (
<p className={"text-xs " + (dtCheck.ok ? "text-success" : "text-danger")}>
{dtCheck.ok ? "اتصال برقرار است." : dtCheck.message}
</p>
)}
{dtSaved && (
<p className={"text-xs " + (dtSaved.last_test_ok === false ? "text-danger" : "text-success")}>
{dtSaved.last_test_ok === false
? `هدف ذخیره شد (آخرین تست ناموفق: ${dtSaved.last_test_message ?? "—"})`
: "هدف انتشار ذخیره شد."}
</p>
)}
{canEdit && (
<div className="flex gap-2">
<Button variant="secondary" onClick={checkDeploy} loading={dtChecking}>
تست اتصال
</Button>
<Button onClick={saveDeploy} loading={savingDt}>
ذخیره هدف
</Button>
</div>
)}
</div>
</Card>
</div>
);
}
+771 -67
View File
@@ -2,53 +2,343 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { canCreateOrganization } from "@agy/contracts"; import {
import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui"; canCreateOrganization,
import { ApiError, api } from "@/lib/api"; type CatalogDefaults,
type CatalogFramework,
type CatalogLanguage,
type CatalogPayload,
type WizardCompleteResult,
} from "@agy/contracts";
import {
Button,
Card,
CardHint,
CardTitle,
ErrorState,
Field,
LoadingState,
Select,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { setupApi } from "@/lib/api-org";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
/** Every editable field of the wizard, kept as strings for uncontrolled inputs. */
interface WizardForm {
org_name: string;
slug: string;
app_name: string;
app_type: string;
language: string;
framework: string;
runtime_version: string;
package_manager: string;
setup_command: string;
build_command: string;
test_command: string;
start_command: string;
app_port: string;
healthcheck_path: string;
git_source: "platform" | "external";
external_repo_url: string;
external_git_auth_type: string;
external_git_username: string;
external_git_secret: string;
deploy_kind: string;
deploy_host: string;
deploy_port: string;
deploy_username: string;
deploy_auth_type: string;
deploy_secret: string;
deploy_remote_path: string;
}
const EMPTY_FORM: WizardForm = {
org_name: "",
slug: "",
app_name: "",
app_type: "",
language: "",
framework: "",
runtime_version: "",
package_manager: "",
setup_command: "",
build_command: "",
test_command: "",
start_command: "",
app_port: "",
healthcheck_path: "/",
git_source: "platform",
external_repo_url: "",
external_git_auth_type: "token",
external_git_username: "",
external_git_secret: "",
deploy_kind: "",
deploy_host: "",
deploy_port: "22",
deploy_username: "",
deploy_auth_type: "password",
deploy_secret: "",
deploy_remote_path: "/",
};
const STEP_TITLES = [
"سازمان",
"اپلیکیشن",
"مخزن گیت",
"تولید (اختیاری)",
"بازبینی",
];
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 120);
}
/** /**
* First-run flow: create an organization. * First-run flow for an invited manager: a 5-step wizard that creates one
* organization with exactly one application, wires up git (platform or the
* user's own repo) and optionally a production deploy target.
* *
* Only the platform owner and the managers they create may do this. A plain * The backend enforces 1 org = 1 app, so a manager who already has an org is
* account reaches this screen with nothing to do but wait for an invite, and * bounced to the dashboard instead of being shown a dead-end form.
* saying so is more honest than showing a form the API would reject.
*/ */
export default function OnboardingPage() { export default function OnboardingPage() {
const router = useRouter(); const router = useRouter();
const { ready, user, organizations, refresh } = useSession(); const { ready, user, organizations, refresh, setActiveOrganization } = useSession();
const [name, setName] = React.useState("");
const [catalog, setCatalog] = React.useState<CatalogPayload | null>(null);
const [step, setStep] = React.useState(1);
const [form, setForm] = React.useState<WizardForm>(EMPTY_FORM);
const [slugTouched, setSlugTouched] = React.useState(false);
const [loadingCatalog, setLoadingCatalog] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [submitting, setSubmitting] = React.useState(false);
// per-step transient status (git/deploy checks, creating)
const [gitCheck, setGitCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
const [gitChecking, setGitChecking] = React.useState(false);
const [deployCheck, setDeployCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
const [deployChecking, setDeployChecking] = React.useState(false);
const [result, setResult] = React.useState<WizardCompleteResult | null>(null);
React.useEffect(() => { React.useEffect(() => {
if (ready && !user) router.replace("/login"); if (ready && !user) router.replace("/login");
}, [ready, user, router]); }, [ready, user, router]);
// Somebody who already has an organization has nothing to do here — the wizard
// is for managers the owner invited who have built nothing yet. Without this,
// the page is a dead end that happily creates a *second* organization.
React.useEffect(() => { React.useEffect(() => {
if (!ready || !user) return; if (!ready || !user) return;
if (organizations.length > 0) router.replace("/dashboard"); if (organizations.length > 0) router.replace("/dashboard");
}, [ready, user, organizations, router]); }, [ready, user, organizations, router]);
const onSubmit = async (event: React.FormEvent) => { // Load catalog + any resumable draft.
event.preventDefault(); React.useEffect(() => {
if (!ready || !user) return;
let cancelled = false;
(async () => {
try {
const [cat, draft] = await Promise.all([setupApi.getCatalog(), setupApi.getDraft()]);
if (cancelled) return;
setCatalog(cat);
if (draft && draft.data && Object.keys(draft.data).length > 0) {
setForm((prev) => ({ ...prev, ...(draft.data as Partial<WizardForm>) }));
setStep(Math.min(Math.max(draft.step, 1), 5));
if ((draft.data as Partial<WizardForm>).slug) setSlugTouched(true);
}
} catch (err) {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "بارگذاری راه‌انداز ناموفق بود.");
} finally {
if (!cancelled) setLoadingCatalog(false);
}
})();
return () => {
cancelled = true;
};
}, [ready, user]);
const update = <K extends keyof WizardForm>(key: K, value: WizardForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const selectedLanguage = React.useMemo<CatalogLanguage | null>(
() => catalog?.languages.find((l) => l.key === form.language) ?? null,
[catalog, form.language],
);
const selectedFramework = React.useMemo<CatalogFramework | null>(() => {
if (!selectedLanguage) return null;
return selectedLanguage.frameworks.find((f) => f.key === form.framework) ?? null;
}, [selectedLanguage, form.framework]);
const applyFrameworkDefaults = (fw: CatalogFramework) => {
const d: CatalogDefaults = fw.defaults;
setForm((prev) => ({
...prev,
framework: fw.key,
app_type: fw.app_type,
runtime_version: d.runtime_version,
package_manager: d.package_manager,
setup_command: d.setup_command ?? "",
build_command: d.build_command ?? "",
test_command: d.test_command ?? "",
start_command: d.start_command,
app_port: String(d.app_port),
healthcheck_path: d.healthcheck_path,
}));
};
const persistDraft = React.useCallback(
async (nextStep: number, data: WizardForm) => {
setSaving(true);
try {
// Secrets are held only in the final submit request — never persisted
// to the resumable draft. Strip them so a resumed draft never leaks a
// token or key through logs, the DB row or a shared machine.
const { external_git_secret, deploy_secret, ...safe } = data;
void external_git_secret;
void deploy_secret;
await setupApi.saveDraft(nextStep, safe as unknown as Record<string, unknown>);
} catch {
// Draft auto-save is best-effort; never block navigation on it.
} finally {
setSaving(false);
}
},
[],
);
const goNext = () => {
const next = Math.min(step + 1, 5);
setStep(next);
void persistDraft(next, form);
};
const goBack = () => {
const prev = Math.max(step - 1, 1);
setStep(prev);
void persistDraft(prev, form);
};
const onOrgNameChange = (value: string) => {
setForm((prev) => ({
...prev,
org_name: value,
slug: slugTouched ? prev.slug : slugify(value),
}));
};
const runGitCheck = async () => {
setGitChecking(true);
setGitCheck(null);
setError(null); setError(null);
setSubmitting(true);
try { try {
await api.createOrganization(name.trim()); const res = await setupApi.checkGit({
await refresh(); url: form.external_repo_url,
router.replace("/dashboard"); auth_type: form.external_git_auth_type,
username: form.external_git_username || null,
secret: form.external_git_secret || null,
});
setGitCheck({ ok: res.ok, message: res.message });
if (res.default_branch) update("healthcheck_path", form.healthcheck_path || "/");
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود."); setGitCheck({
ok: false,
message: err instanceof ApiError ? err.message : "بررسی اتصال ناموفق بود.",
});
} finally { } finally {
setSubmitting(false); setGitChecking(false);
} }
}; };
if (!ready) { const runDeployCheck = async () => {
if (!form.deploy_kind) return;
setDeployChecking(true);
setDeployCheck(null);
setError(null);
try {
const res = await setupApi.checkDeploy({
kind: form.deploy_kind,
host: form.deploy_host,
port: Number(form.deploy_port) || 22,
username: form.deploy_username,
auth_type: form.deploy_auth_type,
secret: form.deploy_secret || null,
remote_path: form.deploy_remote_path || "/",
});
setDeployCheck({ ok: res.ok, message: res.message });
} catch (err) {
setDeployCheck({
ok: false,
message: err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.",
});
} finally {
setDeployChecking(false);
}
};
const buildPayload = (): import("@agy/contracts").WizardCompleteBody => {
const body: import("@agy/contracts").WizardCompleteBody = {
org_name: form.org_name.trim(),
slug: form.slug.trim(),
app_name: form.app_name.trim(),
app_type: form.app_type,
language: form.language,
framework: form.framework,
runtime_version: form.runtime_version || null,
package_manager: form.package_manager || null,
setup_command: form.setup_command || null,
build_command: form.build_command || null,
test_command: form.test_command || null,
start_command: form.start_command || null,
app_port: form.app_port ? Number(form.app_port) : null,
healthcheck_path: form.healthcheck_path || "/",
git_source: form.git_source,
};
if (form.git_source === "external") {
body.external_repo_url = form.external_repo_url.trim() || null;
body.external_git_auth_type = form.external_git_auth_type;
body.external_git_username = form.external_git_username || null;
body.external_git_secret = form.external_git_secret || null;
}
if (form.deploy_kind) {
body.deploy_target_kind = form.deploy_kind;
body.deploy_target_host = form.deploy_host.trim();
body.deploy_target_port = Number(form.deploy_port) || 22;
body.deploy_target_username = form.deploy_username.trim();
body.deploy_target_auth_type = form.deploy_auth_type;
body.deploy_target_secret = form.deploy_secret || null;
body.deploy_target_remote_path = form.deploy_remote_path || "/";
}
return body;
};
const onSubmit = async () => {
setError(null);
setSaving(true);
try {
const res = await setupApi.complete(buildPayload());
setResult(res);
await refresh();
if (res.organization?.id) setActiveOrganization(res.organization.id);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود.");
} finally {
setSaving(false);
}
};
// ----- guards / loading ---------------------------------------------------
if (!ready || loadingCatalog) {
return ( return (
<div className="flex min-h-screen items-center justify-center p-8"> <div className="flex min-h-screen items-center justify-center p-8">
<LoadingState /> <LoadingState />
@@ -62,63 +352,477 @@ export default function OnboardingPage() {
<Card className="anim-rise w-full max-w-md"> <Card className="anim-rise w-full max-w-md">
<CardTitle>در انتظار دعوت</CardTitle> <CardTitle>در انتظار دعوت</CardTitle>
<CardHint> <CardHint>
حساب شما در حال حاضر عضو هیچ سازمانی نیست و ساخت سازمان برای کاربران حساب شما در حال حاضر عضو هیچ سازمانی نیست و ساخت سازمان برای کاربران عادی فعال
عادی فعال نیست. برای شروع کار، از مدیر سازمان خود بخواهید لینک دعوت نیست. برای شروع کار، از مدیر سازمان خود بخواهید لینک دعوت برایتان بفرستد.
برایتان بفرستد.
</CardHint> </CardHint>
{organizations.length > 0 && (
<div className="mt-5">
<Button className="w-full" onClick={() => router.replace("/dashboard")}>
بازگشت به داشبورد
</Button>
</div>
)}
</Card> </Card>
</div> </div>
); );
} }
if (result) {
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-md text-center">
<CardTitle>سازمان ساخته شد</CardTitle>
<CardHint>
«{result.organization.name}» و اپلیکیشن «{result.project.name}» آماده شدند.
حالا می‌توانید تیم هوش‌مصنوعی خود را راه‌بیندازید.
</CardHint>
<div className="mt-5">
<Button className="w-full" onClick={() => router.replace("/manager")}>
برو به مدیر AI
</Button>
</div>
</Card>
</div>
);
}
// ----- step body ----------------------------------------------------------
return ( return (
<div className="flex min-h-screen items-center justify-center p-5"> <div className="flex min-h-screen items-center justify-center p-5">
<Card className="anim-rise w-full max-w-md"> <Card className="anim-rise w-full max-w-xl">
<CardTitle>ساخت اولین سازمان</CardTitle> <Stepper step={step} />
<CardHint>
سازمان ظرف اصلی محصول است: اعضا، پروژه‌ها، کارمندهای هوش مصنوعی و مصرف
همه به سازمان تعلق دارند.
</CardHint>
{organizations.length > 0 && ( {error && <div className="mt-4"><ErrorState title="خطا" message={error} /></div>}
<div className="mt-4 flex flex-col gap-2">
<p className="text-xs text-slate-400">سازمان‌های فعلی شما:</p>
{organizations.map((org) => (
<button
key={org.id}
type="button"
onClick={() => router.replace("/dashboard")}
className="transition-colors rounded-lg border border-surface-border px-3 py-2 text-right text-xs text-slate-200 hover:bg-surface-overlay"
>
{org.name}
</button>
))}
<div className="my-2 border-t border-surface-border" />
</div>
)}
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-4"> <div className="mt-4">
<Field {step === 1 && (
label="نام سازمان" <OrgStep form={form} onOrgName={onOrgNameChange} onSlug={(v) => { setSlugTouched(true); update("slug", v); }} />
required )}
minLength={2} {step === 2 && catalog && (
value={name} <AppStep
onChange={(e) => setName(e.target.value)} form={form}
placeholder="مثلاً شرکت نمونه" catalog={catalog}
/> update={update}
{error && <ErrorState title="ساخت سازمان ناموفق" message={error} />} onFramework={applyFrameworkDefaults}
<Button type="submit" loading={submitting}> />
ساخت سازمان )}
{step === 3 && (
<GitStep
form={form}
update={update}
gitCheck={gitCheck}
gitChecking={gitChecking}
onCheck={runGitCheck}
/>
)}
{step === 4 && (
<ProductionStep
form={form}
update={update}
deployCheck={deployCheck}
deployChecking={deployChecking}
onCheck={runDeployCheck}
/>
)}
{step === 5 && <ReviewStep form={form} catalog={catalog} language={selectedLanguage} framework={selectedFramework} />}
</div>
<div className="mt-6 flex items-center justify-between gap-3">
<Button variant="secondary" onClick={goBack} disabled={step === 1 || saving}>
قبلی
</Button> </Button>
</form> {step < 5 ? (
<Button onClick={goNext} disabled={saving}>
بعدی
</Button>
) : (
<Button onClick={onSubmit} loading={saving} disabled={saving}>
ساخت سازمان
</Button>
)}
</div>
</Card> </Card>
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Stepper
// ---------------------------------------------------------------------------
function Stepper({ step }: { step: number }) {
return (
<div className="flex flex-col gap-3">
<CardTitle>راه‌اندازی سازمان</CardTitle>
<ol className="flex items-center gap-1">
{STEP_TITLES.map((title, idx) => {
const n = idx + 1;
const state = n === step ? "current" : n < step ? "done" : "todo";
return (
<li key={title} className="flex flex-1 items-center gap-1">
<span
className={
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs " +
(state === "current"
? "bg-accent text-white"
: state === "done"
? "bg-accent/30 text-accent"
: "bg-surface-overlay text-slate-500")
}
>
{n}
</span>
<span
className={
"hidden text-xs sm:inline " +
(state === "todo" ? "text-slate-500" : "text-slate-200")
}
>
{title}
</span>
{n < STEP_TITLES.length && <span className="h-px flex-1 bg-surface-border" />}
</li>
);
})}
</ol>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 1 — Organization
// ---------------------------------------------------------------------------
function OrgStep({
form,
onOrgName,
onSlug,
}: {
form: WizardForm;
onOrgName: (v: string) => void;
onSlug: (v: string) => void;
}) {
return (
<div className="flex flex-col gap-4">
<CardHint>سازمان ظرف اصلی محصول است. هر سازمان دقیقاً یک اپلیکیشن دارد.</CardHint>
<Field
label="نام سازمان"
required
minLength={2}
value={form.org_name}
onChange={(e) => onOrgName(e.target.value)}
placeholder="مثلاً شرکت نمونه"
/>
<Field
label="نامک (slug)"
required
value={form.slug}
onChange={(e) => onSlug(e.target.value)}
hint="فقط حروف کوچک انگلیسی، عدد و خط تیره. در نشانی دمو استفاده می‌شود."
className="ltr-inline"
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 2 — Application
// ---------------------------------------------------------------------------
function AppStep({
form,
catalog,
update,
onFramework,
}: {
form: WizardForm;
catalog: CatalogPayload;
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
onFramework: (fw: CatalogFramework) => void;
}) {
const [showAdvanced, setShowAdvanced] = React.useState(false);
const language = catalog.languages.find((l) => l.key === form.language) ?? null;
return (
<div className="flex flex-col gap-4">
<CardHint>نوع، زبان و چارچوب اپلیکیشن را انتخاب کنید.</CardHint>
<Field
label="نام اپلیکیشن"
required
value={form.app_name}
onChange={(e) => update("app_name", e.target.value)}
placeholder="مثلاً پنل مشتریان"
/>
<Select
label="نوع اپلیکیشن"
value={form.app_type}
onChange={(e) => update("app_type", e.target.value)}
options={[
{ value: "", label: "انتخاب کنید…" },
...catalog.app_types.map((t) => ({
value: t,
label: catalog.app_type_labels[t] ?? t,
})),
]}
/>
<Select
label="زبان"
value={form.language}
onChange={(e) => {
update("language", e.target.value);
update("framework", "");
}}
options={[
{ value: "", label: "انتخاب کنید…" },
...catalog.languages.map((l) => ({ value: l.key, label: l.display })),
]}
/>
<Select
label="چارچوب"
value={form.framework}
disabled={!language}
onChange={(e) => {
const fw = language?.frameworks.find((f) => f.key === e.target.value);
if (fw) onFramework(fw);
}}
options={[
{ value: "", label: language ? "انتخاب کنید…" : "اول زبان را انتخاب کنید" },
...(language?.frameworks.map((f) => ({ value: f.key, label: f.display })) ?? []),
]}
/>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="self-start text-xs text-accent hover:underline"
>
{showAdvanced ? "بستن تنظیمات پیشرفته" : "تنظیمات پیشرفته (دستورات و پورت)"}
</button>
{showAdvanced && (
<div className="flex flex-col gap-3 rounded-lg border border-surface-border p-3">
<div className="grid grid-cols-2 gap-3">
<Field label="نسخه runtime" value={form.runtime_version} onChange={(e) => update("runtime_version", e.target.value)} className="ltr-inline" />
<Field label="مدیر بسته" value={form.package_manager} onChange={(e) => update("package_manager", e.target.value)} className="ltr-inline" />
</div>
<Field label="دستور نصب" value={form.setup_command} onChange={(e) => update("setup_command", e.target.value)} className="ltr-inline" />
<Field label="دستور ساخت" value={form.build_command} onChange={(e) => update("build_command", e.target.value)} className="ltr-inline" />
<Field label="دستور تست" value={form.test_command} onChange={(e) => update("test_command", e.target.value)} className="ltr-inline" />
<Field label="دستور اجرا" value={form.start_command} onChange={(e) => update("start_command", e.target.value)} className="ltr-inline" />
<div className="grid grid-cols-2 gap-3">
<Field label="پورت اپلیکیشن" type="number" value={form.app_port} onChange={(e) => update("app_port", e.target.value)} className="ltr-inline" />
<Field label="مسیر healthcheck" value={form.healthcheck_path} onChange={(e) => update("healthcheck_path", e.target.value)} className="ltr-inline" />
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 3 — Git
// ---------------------------------------------------------------------------
function GitStep({
form,
update,
gitCheck,
gitChecking,
onCheck,
}: {
form: WizardForm;
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
gitCheck: { ok: boolean; message?: string } | null;
gitChecking: boolean;
onCheck: () => void;
}) {
return (
<div className="flex flex-col gap-4">
<CardHint>مخزن گیت اپلیکیشن را انتخاب کنید. گیت را همیشه پلتفرم مدیریت می‌کند.</CardHint>
<Select
label="منبع گیت"
value={form.git_source}
onChange={(e) => update("git_source", e.target.value as WizardForm["git_source"])}
options={[
{ value: "platform", label: "Git پلتفرم (پیشنهادی)" },
{ value: "external", label: "Git خودم" },
]}
/>
{form.git_source === "platform" ? (
<p className="rounded-lg border border-surface-border bg-surface-overlay p-3 text-xs text-slate-300">
پلتفرم یک مخزن خصوصی برایتان می‌سازد و کلیدهای دسترسی را امن نگه می‌دارد. نیازی به
تنظیم چیزی نیست.
</p>
) : (
<div className="flex flex-col gap-3">
<Field
label="آدرس مخزن (HTTPS یا SSH)"
required
value={form.external_repo_url}
onChange={(e) => update("external_repo_url", e.target.value)}
placeholder="https://github.com/owner/repo.git"
className="ltr-inline"
/>
<Select
label="نوع احراز"
value={form.external_git_auth_type}
onChange={(e) => update("external_git_auth_type", e.target.value)}
options={[
{ value: "token", label: "توکن (access token)" },
{ value: "ssh", label: "کلید SSH" },
]}
/>
<Field
label="نام کاربری (اختیاری)"
value={form.external_git_username}
onChange={(e) => update("external_git_username", e.target.value)}
className="ltr-inline"
/>
<Field
label={form.external_git_auth_type === "ssh" ? "کلید خصوصی SSH" : "توکن / رمز"}
type="password"
required
value={form.external_git_secret}
onChange={(e) => update("external_git_secret", e.target.value)}
hint="رمز فقط در درخواست نهایی فرستاده می‌شود و هرگز در پیش‌نویس ذخیره نمی‌گردد."
className="ltr-inline"
/>
<div>
<Button variant="secondary" onClick={onCheck} loading={gitChecking}>
بررسی اتصال
</Button>
</div>
{gitCheck && (
<p className={"text-xs " + (gitCheck.ok ? "text-success" : "text-danger")}>
{gitCheck.ok ? "اتصال برقرار است." : gitCheck.message}
</p>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 4 — Production (optional)
// ---------------------------------------------------------------------------
function ProductionStep({
form,
update,
deployCheck,
deployChecking,
onCheck,
}: {
form: WizardForm;
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
deployCheck: { ok: boolean; message?: string } | null;
deployChecking: boolean;
onCheck: () => void;
}) {
const skip = !form.deploy_kind;
return (
<div className="flex flex-col gap-4">
<CardHint>
انتشار در تولید فقط وقتی انجام می‌شود که خودتان دکمه را بزنید. اینجا فقط تنظیمات را
ثبت می‌کنید؛ هیچ کدی بدون کلیک شما به تولید نمی‌رود.
</CardHint>
<Select
label="نوع انتشار (اختیاری)"
value={form.deploy_kind}
onChange={(e) => update("deploy_kind", e.target.value)}
options={[
{ value: "", label: "رد کردن (فعلاً نمی‌خواهم)" },
{ value: "ssh", label: "SSH" },
{ value: "sftp", label: "SFTP" },
{ value: "ftp", label: "FTP" },
{ value: "ftps", label: "FTPS" },
]}
/>
{!skip && (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-3">
<Field label="میزبان" required value={form.deploy_host} onChange={(e) => update("deploy_host", e.target.value)} className="ltr-inline" />
<Field label="پورت" type="number" value={form.deploy_port} onChange={(e) => update("deploy_port", e.target.value)} className="ltr-inline" />
<Field label="نام کاربری" required value={form.deploy_username} onChange={(e) => update("deploy_username", e.target.value)} className="ltr-inline" />
</div>
<Select
label="نوع احراز"
value={form.deploy_auth_type}
onChange={(e) => update("deploy_auth_type", e.target.value)}
options={[
{ value: "password", label: "رمز عبور" },
{ value: "ssh_key", label: "کلید SSH" },
]}
/>
<Field
label={form.deploy_auth_type === "ssh_key" ? "کلید خصوصی SSH" : "رمز عبور"}
type="password"
required
value={form.deploy_secret}
onChange={(e) => update("deploy_secret", e.target.value)}
className="ltr-inline"
/>
<Field label="مسیر مقصد" value={form.deploy_remote_path} onChange={(e) => update("deploy_remote_path", e.target.value)} className="ltr-inline" />
<div>
<Button variant="secondary" onClick={onCheck} loading={deployChecking}>
تست اتصال
</Button>
</div>
{deployCheck && (
<p className={"text-xs " + (deployCheck.ok ? "text-success" : "text-danger")}>
{deployCheck.ok ? "اتصال برقرار است." : deployCheck.message}
</p>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Step 5 — Review
// ---------------------------------------------------------------------------
function ReviewStep({
form,
catalog,
language,
framework,
}: {
form: WizardForm;
catalog: CatalogPayload | null;
language: CatalogLanguage | null;
framework: CatalogFramework | null;
}) {
const typeLabel = form.app_type ? (catalog?.app_type_labels[form.app_type] ?? form.app_type) : "—";
const rows: Array<[string, string]> = [
["سازمان", form.org_name || "—"],
["نامک", form.slug || "—"],
["اپلیکیشن", form.app_name || "—"],
["نوع", typeLabel],
["زبان", language?.display ?? "—"],
["چارچوب", framework?.display ?? "—"],
["گیت", form.git_source === "platform" ? "Git پلتفرم" : "مخزن خودم"],
];
if (form.git_source === "external") {
rows.push(["آدرس مخزن", form.external_repo_url || "—"]);
rows.push(["رمز گیت", form.external_git_secret ? "•••••••• (ثبت می‌شود)" : "—"]);
}
rows.push(["تولید", form.deploy_kind ? form.deploy_kind.toUpperCase() : "رد شده"]);
if (form.deploy_kind) {
rows.push(["میزبان تولید", form.deploy_host || "—"]);
rows.push(["رمز تولید", form.deploy_secret ? "•••••••• (ثبت می‌شود)" : "—"]);
}
return (
<div className="flex flex-col gap-3">
<CardHint>همه‌چیز درست است؟ با زدن «ساخت سازمان» ثبت نهایی انجام می‌شود.</CardHint>
<dl className="divide-y divide-surface-border rounded-lg border border-surface-border">
{rows.map(([k, v]) => (
<div key={k} className="flex items-center justify-between gap-3 px-3 py-2">
<dt className="text-xs text-slate-400">{k}</dt>
<dd className="ltr-inline max-w-[60%] truncate text-xs text-slate-100">{v}</dd>
</div>
))}
</dl>
</div>
);
}
+76
View File
@@ -5,8 +5,19 @@
*/ */
import type { import type {
AppProfile,
AppProfileUpdate,
CatalogPayload,
CheckDeployBody,
CheckGitBody,
DeployTargetPutBody,
DeployTargetSummary,
GitCredentialsPutBody,
IssuedPlatformInvite, IssuedPlatformInvite,
PlatformInvite, PlatformInvite,
SetupDraft,
WizardCompleteBody,
WizardCompleteResult,
} from "@agy/contracts"; } from "@agy/contracts";
import { apiRequest } from "./api"; import { apiRequest } from "./api";
@@ -68,6 +79,71 @@ export const orgApi = {
}; };
/**
* The new-organization wizard lives outside any org (the org does not exist
* yet), so its endpoints are under `/setup/*` and take no org id. The wizard
* stores a resumable draft server-side; secrets are only ever sent in the
* final `complete` request and are never written to the draft.
*/
export const setupApi = {
/** Catalog of app types, languages and framework defaults (v2 §2.1). */
getCatalog: () => apiRequest<CatalogPayload>("/api/setup/catalog"),
/** Resume an in-progress draft (step + collected fields, no secrets). */
getDraft: () => apiRequest<SetupDraft>("/api/setup/draft"),
/** Persist the current step + fields. Returns nothing (204). */
saveDraft: (step: number, data: Record<string, unknown>) =>
apiRequest<void>("/api/setup/draft", { method: "PUT", body: { step, data } }),
/** Probe an external git URL (format + DNS/SSRF check runs server-side). */
checkGit: (body: CheckGitBody) =>
apiRequest<{ ok: boolean; default_branch?: string; branches?: string[]; message?: string }>(
"/api/setup/check-git",
{ method: "POST", body },
),
/** Probe a production deploy target connection (TOFU fingerprint on success). */
checkDeploy: (body: CheckDeployBody) =>
apiRequest<{ ok: boolean; message?: string; fingerprint?: string }>(
"/api/setup/check-deploy",
{ method: "POST", body },
),
/** Create the org + single app + optional git/deploy in one transaction. */
complete: (body: WizardCompleteBody) =>
apiRequest<WizardCompleteResult>("/api/setup/complete", { method: "POST", body }),
};
/**
* Org-scoped application profile (the single app of the org, v2 D1/D2). Owner
* and admin may edit; secrets are write-only and come back only as `has_secret`.
*/
export const appProfileApi = {
get: (orgId: string) =>
apiRequest<AppProfile>(`/api/organizations/${orgId}/app/profile`),
update: (orgId: string, values: AppProfileUpdate) =>
apiRequest<AppProfile>(`/api/organizations/${orgId}/app/profile`, {
method: "PATCH",
body: values,
}),
putDeployTarget: (orgId: string, body: DeployTargetPutBody) =>
apiRequest<DeployTargetSummary>(`/api/organizations/${orgId}/app/deploy-target`, {
method: "PUT",
body,
}),
putGitCredentials: (orgId: string, body: GitCredentialsPutBody) =>
apiRequest<{ git_source: string; has_secret: boolean }>(
`/api/organizations/${orgId}/app/git`,
{ method: "PUT", body },
),
};
/** /**
* True when the signed-in account still needs the creation wizard: a manager * 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. * with no organization yet (v2 D12). The owner is excluded by the server.
+170
View File
@@ -0,0 +1,170 @@
/**
* §v2-A1.2 — the 5-step organization-creation wizard (frontend §2.7).
*
* The wizard is the only path that creates an org + single app, so the tests
* focus on what would silently corrupt the product: the catalog loading, step
* navigation, and — critically — that a typed git/deploy secret is never
* written into the resumable draft payload (only the final submit carries it).
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
session: {
ready: true,
user: { id: "u1", email: "m@e.com", platform_role: "manager" } as Record<string, unknown>,
organizations: [] as Array<Record<string, unknown>>,
activeOrganization: null as Record<string, unknown> | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
setupApi: {
getCatalog: vi.fn(),
getDraft: vi.fn(),
saveDraft: vi.fn((_step: number, _data: Record<string, unknown>) => Promise.resolve()),
checkGit: vi.fn(async () => ({ ok: true })),
checkDeploy: vi.fn(async () => ({ ok: true })),
complete: vi.fn(async () => ({
organization: { id: "org-1", name: "Acme", slug: "acme" },
project: { id: "p-1", name: "app", slug: "app" },
deploy_target_id: null,
})),
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace }),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
return { ApiError: FakeApiError };
});
vi.mock("@/lib/api-org", () => ({ setupApi: mocks.setupApi }));
const replace = vi.fn();
Object.assign(mocks, { replace });
import OnboardingPage from "@/app/onboarding/page";
const CATALOG = {
app_types: ["web_app"],
app_type_labels: { web_app: "وب‌اپ" },
git_sources: ["platform", "external"],
languages: [
{
key: "node",
display: "Node.js",
frameworks: [
{
key: "next",
display: "Next.js",
app_type: "web_app",
defaults: {
runtime_version: "20",
package_manager: "npm",
setup_command: "npm ci",
build_command: "npm run build",
test_command: "npm test",
start_command: "npm start",
app_port: 3000,
healthcheck_path: "/",
docker_image: "node:20",
},
},
],
},
],
};
beforeEach(() => {
vi.clearAllMocks();
mocks.session.ready = true;
mocks.session.user = { id: "u1", email: "m@e.com", platform_role: "manager" };
mocks.session.organizations = [];
mocks.session.activeOrganization = null;
mocks.setupApi.getCatalog.mockResolvedValue(CATALOG);
mocks.setupApi.getDraft.mockResolvedValue({ step: 1, data: {} });
});
describe("the organization wizard", () => {
it("loads the catalog and opens on step 1 (سازمان)", async () => {
render(<OnboardingPage />);
await waitFor(() => expect(screen.getByText("نام سازمان")).toBeInTheDocument());
expect(mocks.setupApi.getCatalog).toHaveBeenCalledTimes(1);
});
it("auto-suggests a slug from the org name", async () => {
render(<OnboardingPage />);
const name = await screen.findByLabelText("نام سازمان");
fireEvent.change(name, { target: { value: "Acme Corp" } });
const slug = screen.getByLabelText("نامک (slug)") as HTMLInputElement;
expect(slug.value).toBe("acme-corp");
});
it("moves to step 2 (اپلیکیشن) on next", async () => {
render(<OnboardingPage />);
const name = await screen.findByLabelText("نام سازمان");
fireEvent.change(name, { target: { value: "Acme" } });
fireEvent.click(screen.getByText("بعدی"));
await waitFor(() => expect(screen.getByText("نوع اپلیکیشن")).toBeInTheDocument());
});
it("never writes the git secret into the draft payload", async () => {
// Resume at step 3 (git) with an external repo but no secret saved yet.
mocks.setupApi.getDraft.mockResolvedValue({
step: 3,
data: { git_source: "external", external_repo_url: "https://example.com/x.git" },
});
render(<OnboardingPage />);
const secret = await screen.findByLabelText("توکن / رمز");
fireEvent.change(secret, { target: { value: "supersecret-token-123" } });
// Advance to the production step — this triggers a draft auto-save.
fireEvent.click(screen.getByText("بعدی"));
await waitFor(() =>
expect(mocks.setupApi.saveDraft).toHaveBeenCalled(),
);
const lastCall = mocks.setupApi.saveDraft.mock.calls.at(-1)!;
const payload = lastCall[1] as Record<string, unknown>;
// The draft carries the non-secret repo url...
expect(payload).toMatchObject({ external_repo_url: "https://example.com/x.git" });
// ...but never the secret itself.
expect(payload).not.toHaveProperty("external_git_secret");
expect(JSON.stringify(payload)).not.toContain("supersecret-token-123");
});
it("bounces a manager who already owns an organization to the dashboard", async () => {
mocks.session.organizations = [{ id: "org-x", name: "X", slug: "x" }];
render(<OnboardingPage />);
await waitFor(() => expect(replace).toHaveBeenCalledWith("/dashboard"));
});
it("shows the waiting screen for a plain user who cannot create orgs", async () => {
mocks.session.user = { id: "u2", email: "p@e.com", platform_role: "user" };
render(<OnboardingPage />);
await waitFor(() => expect(screen.getByText("در انتظار دعوت")).toBeInTheDocument());
});
});
+70
View File
@@ -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.
+156
View File
@@ -1093,3 +1093,159 @@ export interface WsDeployStatusData {
status: ProductionDeployStatus; status: ProductionDeployStatus;
message: string; message: string;
} }
/* -- org wizard (v2 A1-2) ------------------------------------------------- */
export interface CatalogPayload {
app_types: string[];
app_type_labels: Record<string, string>;
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<string, unknown>;
}
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"
>
>;