feat(org): org creation wizard + app profile (v2 phase A1-2)
This commit is contained in:
@@ -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("اتصال به آدرسهای داخلی مجاز نیست.")
|
||||
@@ -21,6 +21,7 @@ class AuditAction:
|
||||
PASSWORD_CHANGE = "auth.password_change"
|
||||
ORG_CREATE = "organization.create"
|
||||
ORG_DELETE = "organization.delete"
|
||||
ORG_WIZARD_COMPLETE = "organization.wizard_complete"
|
||||
#: An organization changed hands because its owning manager was demoted or
|
||||
#: deleted — the platform owner takes over. Never automatic for any other
|
||||
#: reason.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -76,4 +76,31 @@ class PlatformInviteRow(Base):
|
||||
return self.expires_at <= reference
|
||||
|
||||
|
||||
__all__ = ["PlatformInviteRow"]
|
||||
class OrgSetupDraftRow(Base):
|
||||
"""A user's in-progress org-creation wizard state.
|
||||
|
||||
The wizard autosaves its current step here so a refresh resumes. By design
|
||||
the payload never contains secrets: git tokens, SSH keys and deploy-target
|
||||
passwords are submitted only on the final ``complete`` call and are held
|
||||
encrypted elsewhere (``project_external_git`` / ``deploy_targets``). Storing
|
||||
them in the draft would defeat the encryption at rest.
|
||||
"""
|
||||
|
||||
__tablename__ = "org_setup_drafts"
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
#: The furthest step the user reached (1..5). Lets the frontend jump back.
|
||||
step: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
#: JSON blob of the non-secret wizard fields. Validated upstream; secrets
|
||||
#: are stripped before this row is written.
|
||||
data_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
|
||||
created_at: Mapped[datetime] = created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
|
||||
|
||||
@@ -6,6 +6,442 @@ route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.container_delivery import (
|
||||
build_delivery_hooks,
|
||||
build_deploy_target_tester,
|
||||
build_repo_workspace,
|
||||
)
|
||||
from app.application.container_engine import build_workforce_bootstrap
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.org_setup_service import OrgSetupService, WizardPayload
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS
|
||||
from app.domain.projects.catalog import APP_TYPES, GIT_SOURCES
|
||||
from app.infrastructure.adapters.registry import get_git_provider
|
||||
from app.infrastructure.db.models import ProjectRow
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_current_user,
|
||||
get_org_context,
|
||||
require_capability,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["app-setup"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CatalogOut(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
catalog: dict
|
||||
|
||||
|
||||
class DraftRequest(BaseModel):
|
||||
step: int = Field(ge=1, le=5)
|
||||
data: dict = {}
|
||||
|
||||
|
||||
class DraftOut(BaseModel):
|
||||
step: int
|
||||
data: dict
|
||||
|
||||
|
||||
class CheckGitRequest(BaseModel):
|
||||
url: str = Field(min_length=1, max_length=500)
|
||||
auth_type: str = "none"
|
||||
username: str | None = None
|
||||
secret: str | None = None
|
||||
|
||||
|
||||
class CheckDeployRequest(BaseModel):
|
||||
kind: str = Field(min_length=1)
|
||||
host: str = Field(min_length=1)
|
||||
port: int = Field(default=22, ge=1, le=65535)
|
||||
username: str = Field(min_length=1)
|
||||
auth_type: str = "password"
|
||||
secret: str | None = None
|
||||
remote_path: str = "/"
|
||||
|
||||
|
||||
class WizardCompleteRequest(BaseModel):
|
||||
org_name: str = Field(min_length=2, max_length=160)
|
||||
slug: str = Field(min_length=2, max_length=120, pattern=r"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")
|
||||
app_name: str = Field(min_length=1, max_length=160)
|
||||
app_type: str = Field(min_length=1)
|
||||
language: str = Field(min_length=1)
|
||||
framework: str = Field(min_length=1)
|
||||
runtime_version: str | None = None
|
||||
package_manager: str | None = None
|
||||
setup_command: str | None = None
|
||||
build_command: str | None = None
|
||||
test_command: str | None = None
|
||||
start_command: str | None = None
|
||||
app_port: int | None = None
|
||||
healthcheck_path: str = "/"
|
||||
git_source: str = "platform"
|
||||
external_repo_url: str | None = None
|
||||
external_git_auth_type: str = "token"
|
||||
external_git_username: str | None = None
|
||||
external_git_secret: str | None = None
|
||||
deploy_target_kind: str | None = None
|
||||
deploy_target_host: str | None = None
|
||||
deploy_target_port: int = 22
|
||||
deploy_target_username: str | None = None
|
||||
deploy_target_auth_type: str = "password"
|
||||
deploy_target_secret: str | None = None
|
||||
deploy_target_remote_path: str = "/"
|
||||
|
||||
@field_validator("app_type")
|
||||
@classmethod
|
||||
def _valid_app_type(cls, v: str) -> str:
|
||||
if v not in APP_TYPES:
|
||||
raise ValueError("نوع اپلیکیشن نامعتبر است.")
|
||||
return v
|
||||
|
||||
@field_validator("git_source")
|
||||
@classmethod
|
||||
def _valid_git_source(cls, v: str) -> str:
|
||||
if v not in GIT_SOURCES:
|
||||
raise ValueError("منبع git نامعتبر است.")
|
||||
return v
|
||||
|
||||
|
||||
class WizardCompleteOut(BaseModel):
|
||||
organization: dict
|
||||
project: dict
|
||||
deploy_target_id: str | None = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
project_id: str
|
||||
app_type: str | None = None
|
||||
language: str | None = None
|
||||
framework: str | None = None
|
||||
runtime_version: str | None = None
|
||||
package_manager: str | None = None
|
||||
setup_command: str | None = None
|
||||
build_command: str | None = None
|
||||
test_command: str | None = None
|
||||
start_command: str | None = None
|
||||
app_port: int | None = None
|
||||
healthcheck_path: str = "/"
|
||||
git_source: str = "platform"
|
||||
external_repo_url: str | None = None
|
||||
default_branch: str = "main"
|
||||
wizard_completed: bool = False
|
||||
|
||||
|
||||
class ProfilePatchRequest(BaseModel):
|
||||
app_type: str | None = None
|
||||
language: str | None = None
|
||||
framework: str | None = None
|
||||
runtime_version: str | None = None
|
||||
package_manager: str | None = None
|
||||
setup_command: str | None = None
|
||||
build_command: str | None = None
|
||||
test_command: str | None = None
|
||||
start_command: str | None = None
|
||||
app_port: int | None = None
|
||||
healthcheck_path: str | None = None
|
||||
git_source: str | None = None
|
||||
external_repo_url: str | None = None
|
||||
default_branch: str | None = None
|
||||
|
||||
|
||||
class DeployTargetOut(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
auth_type: str
|
||||
remote_path: str
|
||||
has_secret: bool
|
||||
last_test_ok: bool | None = None
|
||||
last_test_message: str | None = None
|
||||
|
||||
|
||||
class DeployTargetPutRequest(BaseModel):
|
||||
kind: str = Field(min_length=1)
|
||||
host: str = Field(min_length=1)
|
||||
port: int = Field(default=22, ge=1, le=65535)
|
||||
username: str = Field(min_length=1)
|
||||
auth_type: str = "password"
|
||||
secret: str | None = None
|
||||
remote_path: str = "/"
|
||||
|
||||
|
||||
class GitCredentialsPutRequest(BaseModel):
|
||||
auth_type: str = "token"
|
||||
username: str | None = None
|
||||
secret: str | None = None
|
||||
|
||||
|
||||
class GitCredentialsOut(BaseModel):
|
||||
git_source: str
|
||||
has_secret: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_service(session: AsyncSession) -> OrgSetupService:
|
||||
return OrgSetupService(
|
||||
session,
|
||||
repo_workspace=build_repo_workspace(session),
|
||||
deploy_tester=build_deploy_target_tester(session),
|
||||
git_provider=get_git_provider(),
|
||||
workforce_bootstrap=build_workforce_bootstrap(session),
|
||||
delivery_hooks=build_delivery_hooks(session),
|
||||
)
|
||||
|
||||
|
||||
async def _project_for_org(session: AsyncSession, org_id: str) -> ProjectRow:
|
||||
row = await session.scalar(
|
||||
select(ProjectRow).where(ProjectRow.organization_id == org_id)
|
||||
)
|
||||
if row is None:
|
||||
from app.core.errors import NotFoundError
|
||||
|
||||
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
|
||||
return row
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wizard routes (no org context — the org does not exist yet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/setup/catalog", response_model=dict)
|
||||
async def get_catalog(
|
||||
_user=Depends(get_current_user),
|
||||
) -> dict:
|
||||
return OrgSetupService.catalog()
|
||||
|
||||
|
||||
@router.get("/setup/draft", response_model=DraftOut)
|
||||
async def get_draft(
|
||||
user=Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DraftOut:
|
||||
svc = OrgSetupService(session)
|
||||
data = await svc.get_draft(user.id)
|
||||
return DraftOut(step=data["step"], data=data["data"])
|
||||
|
||||
|
||||
@router.put("/setup/draft", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def save_draft(
|
||||
payload: DraftRequest,
|
||||
user=Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
svc = OrgSetupService(session)
|
||||
await svc.save_draft(user.id, step=payload.step, data=payload.data)
|
||||
|
||||
|
||||
@router.post("/setup/check-git", response_model=dict)
|
||||
async def check_git(
|
||||
payload: CheckGitRequest,
|
||||
user=Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
svc = _build_service(session)
|
||||
result = await svc.check_git(
|
||||
url=payload.url,
|
||||
auth_type=payload.auth_type,
|
||||
username=payload.username,
|
||||
secret=payload.secret,
|
||||
)
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"default_branch": result.default_branch,
|
||||
"branches": result.branches,
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/setup/check-deploy", response_model=dict)
|
||||
async def check_deploy(
|
||||
payload: CheckDeployRequest,
|
||||
user=Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
from app.application.ports.deploy_target import DeployTargetSpec
|
||||
|
||||
svc = _build_service(session)
|
||||
result = await svc.check_deploy(
|
||||
target=DeployTargetSpec(
|
||||
kind=payload.kind,
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
username=payload.username,
|
||||
auth_type=payload.auth_type,
|
||||
secret=payload.secret,
|
||||
remote_path=payload.remote_path,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"message": result.message,
|
||||
"fingerprint": result.fingerprint,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/setup/complete",
|
||||
response_model=WizardCompleteOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def complete_wizard(
|
||||
payload: WizardCompleteRequest,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WizardCompleteOut:
|
||||
svc = _build_service(session)
|
||||
result = await svc.complete(
|
||||
user,
|
||||
WizardPayload(
|
||||
org_name=payload.org_name,
|
||||
slug=payload.slug,
|
||||
app_name=payload.app_name,
|
||||
app_type=payload.app_type,
|
||||
language=payload.language,
|
||||
framework=payload.framework,
|
||||
runtime_version=payload.runtime_version,
|
||||
package_manager=payload.package_manager,
|
||||
setup_command=payload.setup_command,
|
||||
build_command=payload.build_command,
|
||||
test_command=payload.test_command,
|
||||
start_command=payload.start_command,
|
||||
app_port=payload.app_port,
|
||||
healthcheck_path=payload.healthcheck_path,
|
||||
git_source=payload.git_source,
|
||||
external_repo_url=payload.external_repo_url,
|
||||
external_git_auth_type=payload.external_git_auth_type,
|
||||
external_git_username=payload.external_git_username,
|
||||
external_git_secret=payload.external_git_secret,
|
||||
deploy_target_kind=payload.deploy_target_kind,
|
||||
deploy_target_host=payload.deploy_target_host,
|
||||
deploy_target_port=payload.deploy_target_port,
|
||||
deploy_target_username=payload.deploy_target_username,
|
||||
deploy_target_auth_type=payload.deploy_target_auth_type,
|
||||
deploy_target_secret=payload.deploy_target_secret,
|
||||
deploy_target_remote_path=payload.deploy_target_remote_path,
|
||||
),
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.ORG_WIZARD_COMPLETE,
|
||||
organization_id=result["organization"]["id"],
|
||||
actor_user_id=user.id,
|
||||
target_type="organization",
|
||||
target_id=result["organization"]["id"],
|
||||
metadata={
|
||||
"org_name": payload.org_name,
|
||||
"slug": payload.slug,
|
||||
"app_name": payload.app_name,
|
||||
"app_type": payload.app_type,
|
||||
"language": payload.language,
|
||||
"framework": payload.framework,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
return WizardCompleteOut(
|
||||
organization=result["organization"],
|
||||
project=result["project"],
|
||||
deploy_target_id=result.get("deploy_target_id"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App profile editing (org-scoped)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/organizations/{org_id}/app/profile", response_model=ProfileOut)
|
||||
async def get_app_profile(
|
||||
org_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProfileOut:
|
||||
project = await _project_for_org(session, org_id)
|
||||
svc = OrgSetupService(session)
|
||||
data = await svc.get_profile(organization_id=org_id, project_id=project.id)
|
||||
return ProfileOut(**data)
|
||||
|
||||
|
||||
@router.patch("/organizations/{org_id}/app/profile", response_model=ProfileOut)
|
||||
async def patch_app_profile(
|
||||
org_id: str,
|
||||
payload: ProfilePatchRequest,
|
||||
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProfileOut:
|
||||
project = await _project_for_org(session, org_id)
|
||||
svc = OrgSetupService(session)
|
||||
values = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
data = await svc.update_profile(
|
||||
organization_id=org_id, project_id=project.id, values=values
|
||||
)
|
||||
return ProfileOut(**data)
|
||||
|
||||
|
||||
@router.put("/organizations/{org_id}/app/deploy-target", response_model=DeployTargetOut)
|
||||
async def put_deploy_target(
|
||||
org_id: str,
|
||||
payload: DeployTargetPutRequest,
|
||||
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DeployTargetOut:
|
||||
project = await _project_for_org(session, org_id)
|
||||
svc = OrgSetupService(session)
|
||||
data = await svc.update_deploy_target(
|
||||
organization_id=org_id,
|
||||
project_id=project.id,
|
||||
kind=payload.kind,
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
username=payload.username,
|
||||
auth_type=payload.auth_type,
|
||||
secret=payload.secret,
|
||||
remote_path=payload.remote_path,
|
||||
)
|
||||
return DeployTargetOut(**data)
|
||||
|
||||
|
||||
@router.put("/organizations/{org_id}/app/git", response_model=GitCredentialsOut)
|
||||
async def put_git_credentials(
|
||||
org_id: str,
|
||||
payload: GitCredentialsPutRequest,
|
||||
context: OrgContext = Depends(require_capability(CAN_MANAGE_PROJECTS)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> GitCredentialsOut:
|
||||
project = await _project_for_org(session, org_id)
|
||||
svc = OrgSetupService(session)
|
||||
data = await svc.update_git_credentials(
|
||||
organization_id=org_id,
|
||||
project_id=project.id,
|
||||
auth_type=payload.auth_type,
|
||||
username=payload.username,
|
||||
secret=payload.secret,
|
||||
)
|
||||
return GitCredentialsOut(**data)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AuditEvent, Invite, Member, Role } from "@agy/contracts";
|
||||
import type { AppProfile, AuditEvent, DeployTargetSummary, Invite, Member, Role } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_MANAGE_PROJECTS,
|
||||
CAN_VIEW_AUDIT,
|
||||
roleHasCapability,
|
||||
} from "@/lib/permissions";
|
||||
import { appProfileApi, setupApi } from "@/lib/api-org";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
@@ -62,7 +64,7 @@ const AUDIT_LABELS: Record<string, string> = {
|
||||
"run.cancel": "لغو اجرا",
|
||||
};
|
||||
|
||||
type Tab = "members" | "audit";
|
||||
type Tab = "members" | "audit" | "app";
|
||||
|
||||
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
|
||||
const INVITE_TTL_DAYS = 14;
|
||||
@@ -206,6 +208,7 @@ export default function SettingsPage() {
|
||||
|
||||
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
|
||||
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
|
||||
const canManageProjects = roleHasCapability(activeOrganization.role, CAN_MANAGE_PROJECTS);
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
@@ -218,6 +221,7 @@ export default function SettingsPage() {
|
||||
{(
|
||||
[
|
||||
["members", "اعضا و دعوت"],
|
||||
["app", "اپلیکیشن"],
|
||||
["audit", "رویدادهای حسابرسی"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
@@ -372,6 +376,8 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : tab === "app" ? (
|
||||
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>رویدادهای حسابرسی</CardTitle>
|
||||
@@ -422,3 +428,343 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "اپلیکیشن" tab: edit the single app's profile, rotate git credentials, and
|
||||
* edit or test the production deploy target. Owner/admin only — viewers see a
|
||||
* read-only summary. (v2 A1-2 §2.8)
|
||||
*/
|
||||
function AppTab({ orgId, canEdit }: { orgId: string; canEdit: boolean }) {
|
||||
const [profile, setProfile] = React.useState<AppProfile | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [form, setForm] = React.useState<Record<string, string>>({});
|
||||
const [savingProfile, setSavingProfile] = React.useState(false);
|
||||
|
||||
// git credential rotation
|
||||
const [gitAuth, setGitAuth] = React.useState("token");
|
||||
const [gitUser, setGitUser] = React.useState("");
|
||||
const [gitSecret, setGitSecret] = React.useState("");
|
||||
const [gitSaved, setGitSaved] = React.useState<string | null>(null);
|
||||
const [savingGit, setSavingGit] = React.useState(false);
|
||||
|
||||
// deploy target
|
||||
const [dt, setDt] = React.useState({
|
||||
kind: "ssh",
|
||||
host: "",
|
||||
port: "22",
|
||||
username: "",
|
||||
auth_type: "password",
|
||||
secret: "",
|
||||
remote_path: "/",
|
||||
});
|
||||
const [dtCheck, setDtCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
|
||||
const [dtChecking, setDtChecking] = React.useState(false);
|
||||
const [dtSaved, setDtSaved] = React.useState<DeployTargetSummary | null>(null);
|
||||
const [savingDt, setSavingDt] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const p = await appProfileApi.get(orgId);
|
||||
setProfile(p);
|
||||
setForm({
|
||||
app_type: p.app_type ?? "",
|
||||
language: p.language ?? "",
|
||||
framework: p.framework ?? "",
|
||||
runtime_version: p.runtime_version ?? "",
|
||||
package_manager: p.package_manager ?? "",
|
||||
setup_command: p.setup_command ?? "",
|
||||
build_command: p.build_command ?? "",
|
||||
test_command: p.test_command ?? "",
|
||||
start_command: p.start_command ?? "",
|
||||
app_port: p.app_port != null ? String(p.app_port) : "",
|
||||
healthcheck_path: p.healthcheck_path ?? "/",
|
||||
external_repo_url: p.external_repo_url ?? "",
|
||||
default_branch: p.default_branch ?? "main",
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت پروفایل ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const setField = (key: string, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSavingProfile(true);
|
||||
setError(null);
|
||||
try {
|
||||
const values: Record<string, string | number | null> = {};
|
||||
for (const k of [
|
||||
"app_type",
|
||||
"language",
|
||||
"framework",
|
||||
"runtime_version",
|
||||
"package_manager",
|
||||
"setup_command",
|
||||
"build_command",
|
||||
"test_command",
|
||||
"start_command",
|
||||
"healthcheck_path",
|
||||
"external_repo_url",
|
||||
"default_branch",
|
||||
]) {
|
||||
const v = form[k] ?? "";
|
||||
values[k] = k === "app_port" ? (v ? Number(v) : null) : v === "" ? null : v;
|
||||
}
|
||||
const updated = await appProfileApi.update(orgId, values as never);
|
||||
setProfile(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ذخیره پروفایل ناموفق بود.");
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGit = async () => {
|
||||
setSavingGit(true);
|
||||
setGitSaved(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await appProfileApi.putGitCredentials(orgId, {
|
||||
auth_type: gitAuth,
|
||||
username: gitUser || null,
|
||||
secret: gitSecret || null,
|
||||
});
|
||||
setGitSaved(res.has_secret ? "اعتبارنامه بهروزرسانی شد." : "اعتبارنامه ثبت شد.");
|
||||
setGitSecret("");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "چرخش اعتبارنامه ناموفق بود.");
|
||||
} finally {
|
||||
setSavingGit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkDeploy = async () => {
|
||||
setDtChecking(true);
|
||||
setDtCheck(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await setupApi.checkDeploy({
|
||||
kind: dt.kind,
|
||||
host: dt.host,
|
||||
port: Number(dt.port) || 22,
|
||||
username: dt.username,
|
||||
auth_type: dt.auth_type,
|
||||
secret: dt.secret || null,
|
||||
remote_path: dt.remote_path || "/",
|
||||
});
|
||||
setDtCheck({ ok: res.ok, message: res.message });
|
||||
} catch (err) {
|
||||
setDtCheck({
|
||||
ok: false,
|
||||
message: err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.",
|
||||
});
|
||||
} finally {
|
||||
setDtChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDeploy = async () => {
|
||||
setSavingDt(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await appProfileApi.putDeployTarget(orgId, {
|
||||
kind: dt.kind,
|
||||
host: dt.host.trim(),
|
||||
port: Number(dt.port) || 22,
|
||||
username: dt.username.trim(),
|
||||
auth_type: dt.auth_type,
|
||||
secret: dt.secret || null,
|
||||
remote_path: dt.remote_path || "/",
|
||||
});
|
||||
setDtSaved(saved);
|
||||
setDt((prev) => ({ ...prev, secret: "" }));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ذخیره هدف انتشار ناموفق بود.");
|
||||
} finally {
|
||||
setSavingDt(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<LoadingState label="در حال دریافت پروفایل اپلیکیشن…" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState title="اپلیکیشنی یافت نشد" description="این سازمان هنوز اپلیکیشنی ندارد." />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const readOnly = !canEdit;
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
{error && <ErrorState message={error} onRetry={load} />}
|
||||
|
||||
{readOnly && (
|
||||
<Card>
|
||||
<CardHint>فقط مالک یا مدیر سازمان میتواند پروفایل اپلیکیشن را ویرایش کند.</CardHint>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Profile */}
|
||||
<Card>
|
||||
<CardTitle>پروفایل اپلیکیشن</CardTitle>
|
||||
<CardHint>
|
||||
اینها همان تنظیماتی هستند که در جادوگر وارد کردید. منبع گیت فقط قابل چرخش
|
||||
اعتبارنامه است و پس از ساخت تغییر نمیکند.
|
||||
</CardHint>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="نوع" value={form.app_type} disabled={readOnly} onChange={(e) => setField("app_type", e.target.value)} className="ltr-inline" />
|
||||
<Field label="زبان" value={form.language} disabled={readOnly} onChange={(e) => setField("language", e.target.value)} className="ltr-inline" />
|
||||
<Field label="چارچوب" value={form.framework} disabled={readOnly} onChange={(e) => setField("framework", e.target.value)} className="ltr-inline" />
|
||||
<Field label="نسخه runtime" value={form.runtime_version} disabled={readOnly} onChange={(e) => setField("runtime_version", e.target.value)} className="ltr-inline" />
|
||||
<Field label="مدیر بسته" value={form.package_manager} disabled={readOnly} onChange={(e) => setField("package_manager", e.target.value)} className="ltr-inline" />
|
||||
<Field label="پورت" type="number" value={form.app_port} disabled={readOnly} onChange={(e) => setField("app_port", e.target.value)} className="ltr-inline" />
|
||||
</div>
|
||||
<Field label="دستور نصب" value={form.setup_command} disabled={readOnly} onChange={(e) => setField("setup_command", e.target.value)} className="ltr-inline" />
|
||||
<Field label="دستور ساخت" value={form.build_command} disabled={readOnly} onChange={(e) => setField("build_command", e.target.value)} className="ltr-inline" />
|
||||
<Field label="دستور تست" value={form.test_command} disabled={readOnly} onChange={(e) => setField("test_command", e.target.value)} className="ltr-inline" />
|
||||
<Field label="دستور اجرا" value={form.start_command} disabled={readOnly} onChange={(e) => setField("start_command", e.target.value)} className="ltr-inline" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="مسیر healthcheck" value={form.healthcheck_path} disabled={readOnly} onChange={(e) => setField("healthcheck_path", e.target.value)} className="ltr-inline" />
|
||||
<Field label="شاخه پیشفرض" value={form.default_branch} disabled={readOnly} onChange={(e) => setField("default_branch", e.target.value)} className="ltr-inline" />
|
||||
</div>
|
||||
<Field label="آدرس مخزن خارجی" value={form.external_repo_url} disabled={readOnly} onChange={(e) => setField("external_repo_url", e.target.value)} className="ltr-inline" />
|
||||
{canEdit && (
|
||||
<Button onClick={saveProfile} loading={savingProfile}>
|
||||
ذخیره تغییرات
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Git credentials rotation */}
|
||||
<Card>
|
||||
<CardTitle>اعتبارنامهٔ گیت</CardTitle>
|
||||
<CardHint>
|
||||
منبع فعلی: {profile.git_source === "platform" ? "Git پلتفرم" : "مخزن خودم"}. تغییر
|
||||
منبع پس از ساخت امکانپذیر نیست؛ فقط اعتبارنامه را بچرخانید.
|
||||
</CardHint>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Select
|
||||
label="نوع احراز"
|
||||
value={gitAuth}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setGitAuth(e.target.value)}
|
||||
options={[
|
||||
{ value: "token", label: "توکن" },
|
||||
{ value: "ssh", label: "کلید SSH" },
|
||||
]}
|
||||
/>
|
||||
<Field label="نام کاربری (اختیاری)" value={gitUser} disabled={readOnly} onChange={(e) => setGitUser(e.target.value)} className="ltr-inline" />
|
||||
<Field
|
||||
label={gitAuth === "ssh" ? "کلید خصوصی SSH" : "توکن / رمز"}
|
||||
type="password"
|
||||
value={gitSecret}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setGitSecret(e.target.value)}
|
||||
hint="خالی بماند تا مقدار فعلی تغییر نکند."
|
||||
className="ltr-inline"
|
||||
/>
|
||||
{gitSaved && <p className="text-xs text-success">{gitSaved}</p>}
|
||||
{canEdit && (
|
||||
<Button variant="secondary" onClick={saveGit} loading={savingGit}>
|
||||
چرخش اعتبارنامه
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Deploy target */}
|
||||
<Card>
|
||||
<CardTitle>هدف انتشار تولید</CardTitle>
|
||||
<CardHint>
|
||||
انتشار در تولید فقط با دکمهٔ خودتان انجام میشود. در اینجا فقط هدف را ثبت یا
|
||||
بهروزرسانی میکنید.
|
||||
</CardHint>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<Select
|
||||
label="نوع"
|
||||
value={dt.kind}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setDt((p) => ({ ...p, kind: e.target.value }))}
|
||||
options={[
|
||||
{ value: "ssh", label: "SSH" },
|
||||
{ value: "sftp", label: "SFTP" },
|
||||
{ value: "ftp", label: "FTP" },
|
||||
{ value: "ftps", label: "FTPS" },
|
||||
]}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="میزبان" value={dt.host} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, host: e.target.value }))} className="ltr-inline" />
|
||||
<Field label="پورت" type="number" value={dt.port} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, port: e.target.value }))} className="ltr-inline" />
|
||||
<Field label="نام کاربری" value={dt.username} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, username: e.target.value }))} className="ltr-inline" />
|
||||
</div>
|
||||
<Select
|
||||
label="نوع احراز"
|
||||
value={dt.auth_type}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setDt((p) => ({ ...p, auth_type: e.target.value }))}
|
||||
options={[
|
||||
{ value: "password", label: "رمز عبور" },
|
||||
{ value: "ssh_key", label: "کلید SSH" },
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
label={dt.auth_type === "ssh_key" ? "کلید خصوصی SSH" : "رمز عبور"}
|
||||
type="password"
|
||||
value={dt.secret}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setDt((p) => ({ ...p, secret: e.target.value }))}
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field label="مسیر مقصد" value={dt.remote_path} disabled={readOnly} onChange={(e) => setDt((p) => ({ ...p, remote_path: e.target.value }))} className="ltr-inline" />
|
||||
|
||||
{dtCheck && (
|
||||
<p className={"text-xs " + (dtCheck.ok ? "text-success" : "text-danger")}>
|
||||
{dtCheck.ok ? "اتصال برقرار است." : dtCheck.message}
|
||||
</p>
|
||||
)}
|
||||
{dtSaved && (
|
||||
<p className={"text-xs " + (dtSaved.last_test_ok === false ? "text-danger" : "text-success")}>
|
||||
{dtSaved.last_test_ok === false
|
||||
? `هدف ذخیره شد (آخرین تست ناموفق: ${dtSaved.last_test_message ?? "—"})`
|
||||
: "هدف انتشار ذخیره شد."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={checkDeploy} loading={dtChecking}>
|
||||
تست اتصال
|
||||
</Button>
|
||||
<Button onClick={saveDeploy} loading={savingDt}>
|
||||
ذخیره هدف
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,53 +2,343 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { canCreateOrganization } from "@agy/contracts";
|
||||
import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
canCreateOrganization,
|
||||
type CatalogDefaults,
|
||||
type CatalogFramework,
|
||||
type CatalogLanguage,
|
||||
type CatalogPayload,
|
||||
type WizardCompleteResult,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { setupApi } from "@/lib/api-org";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** Every editable field of the wizard, kept as strings for uncontrolled inputs. */
|
||||
interface WizardForm {
|
||||
org_name: string;
|
||||
slug: string;
|
||||
app_name: string;
|
||||
app_type: string;
|
||||
language: string;
|
||||
framework: string;
|
||||
runtime_version: string;
|
||||
package_manager: string;
|
||||
setup_command: string;
|
||||
build_command: string;
|
||||
test_command: string;
|
||||
start_command: string;
|
||||
app_port: string;
|
||||
healthcheck_path: string;
|
||||
git_source: "platform" | "external";
|
||||
external_repo_url: string;
|
||||
external_git_auth_type: string;
|
||||
external_git_username: string;
|
||||
external_git_secret: string;
|
||||
deploy_kind: string;
|
||||
deploy_host: string;
|
||||
deploy_port: string;
|
||||
deploy_username: string;
|
||||
deploy_auth_type: string;
|
||||
deploy_secret: string;
|
||||
deploy_remote_path: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: WizardForm = {
|
||||
org_name: "",
|
||||
slug: "",
|
||||
app_name: "",
|
||||
app_type: "",
|
||||
language: "",
|
||||
framework: "",
|
||||
runtime_version: "",
|
||||
package_manager: "",
|
||||
setup_command: "",
|
||||
build_command: "",
|
||||
test_command: "",
|
||||
start_command: "",
|
||||
app_port: "",
|
||||
healthcheck_path: "/",
|
||||
git_source: "platform",
|
||||
external_repo_url: "",
|
||||
external_git_auth_type: "token",
|
||||
external_git_username: "",
|
||||
external_git_secret: "",
|
||||
deploy_kind: "",
|
||||
deploy_host: "",
|
||||
deploy_port: "22",
|
||||
deploy_username: "",
|
||||
deploy_auth_type: "password",
|
||||
deploy_secret: "",
|
||||
deploy_remote_path: "/",
|
||||
};
|
||||
|
||||
const STEP_TITLES = [
|
||||
"سازمان",
|
||||
"اپلیکیشن",
|
||||
"مخزن گیت",
|
||||
"تولید (اختیاری)",
|
||||
"بازبینی",
|
||||
];
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run flow: create an organization.
|
||||
* First-run flow for an invited manager: a 5-step wizard that creates one
|
||||
* organization with exactly one application, wires up git (platform or the
|
||||
* user's own repo) and optionally a production deploy target.
|
||||
*
|
||||
* Only the platform owner and the managers they create may do this. A plain
|
||||
* account reaches this screen with nothing to do but wait for an invite, and
|
||||
* saying so is more honest than showing a form the API would reject.
|
||||
* The backend enforces 1 org = 1 app, so a manager who already has an org is
|
||||
* bounced to the dashboard instead of being shown a dead-end form.
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { ready, user, organizations, refresh } = useSession();
|
||||
const [name, setName] = React.useState("");
|
||||
const { ready, user, organizations, refresh, setActiveOrganization } = useSession();
|
||||
|
||||
const [catalog, setCatalog] = React.useState<CatalogPayload | null>(null);
|
||||
const [step, setStep] = React.useState(1);
|
||||
const [form, setForm] = React.useState<WizardForm>(EMPTY_FORM);
|
||||
const [slugTouched, setSlugTouched] = React.useState(false);
|
||||
|
||||
const [loadingCatalog, setLoadingCatalog] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
// per-step transient status (git/deploy checks, creating)
|
||||
const [gitCheck, setGitCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
|
||||
const [gitChecking, setGitChecking] = React.useState(false);
|
||||
const [deployCheck, setDeployCheck] = React.useState<{ ok: boolean; message?: string } | null>(null);
|
||||
const [deployChecking, setDeployChecking] = React.useState(false);
|
||||
|
||||
const [result, setResult] = React.useState<WizardCompleteResult | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ready && !user) router.replace("/login");
|
||||
}, [ready, user, router]);
|
||||
|
||||
// Somebody who already has an organization has nothing to do here — the wizard
|
||||
// is for managers the owner invited who have built nothing yet. Without this,
|
||||
// the page is a dead end that happily creates a *second* organization.
|
||||
React.useEffect(() => {
|
||||
if (!ready || !user) return;
|
||||
if (organizations.length > 0) router.replace("/dashboard");
|
||||
}, [ready, user, organizations, router]);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
// Load catalog + any resumable draft.
|
||||
React.useEffect(() => {
|
||||
if (!ready || !user) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await api.createOrganization(name.trim());
|
||||
await refresh();
|
||||
router.replace("/dashboard");
|
||||
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) {
|
||||
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود.");
|
||||
if (cancelled) return;
|
||||
setError(err instanceof ApiError ? err.message : "بارگذاری راهانداز ناموفق بود.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
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);
|
||||
try {
|
||||
const res = await setupApi.checkGit({
|
||||
url: form.external_repo_url,
|
||||
auth_type: form.external_git_auth_type,
|
||||
username: form.external_git_username || null,
|
||||
secret: form.external_git_secret || null,
|
||||
});
|
||||
setGitCheck({ ok: res.ok, message: res.message });
|
||||
if (res.default_branch) update("healthcheck_path", form.healthcheck_path || "/");
|
||||
} catch (err) {
|
||||
setGitCheck({
|
||||
ok: false,
|
||||
message: err instanceof ApiError ? err.message : "بررسی اتصال ناموفق بود.",
|
||||
});
|
||||
} finally {
|
||||
setGitChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
const runDeployCheck = async () => {
|
||||
if (!form.deploy_kind) return;
|
||||
setDeployChecking(true);
|
||||
setDeployCheck(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await setupApi.checkDeploy({
|
||||
kind: form.deploy_kind,
|
||||
host: form.deploy_host,
|
||||
port: Number(form.deploy_port) || 22,
|
||||
username: form.deploy_username,
|
||||
auth_type: form.deploy_auth_type,
|
||||
secret: form.deploy_secret || null,
|
||||
remote_path: form.deploy_remote_path || "/",
|
||||
});
|
||||
setDeployCheck({ ok: res.ok, message: res.message });
|
||||
} catch (err) {
|
||||
setDeployCheck({
|
||||
ok: false,
|
||||
message: err instanceof ApiError ? err.message : "تست اتصال ناموفق بود.",
|
||||
});
|
||||
} finally {
|
||||
setDeployChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildPayload = (): import("@agy/contracts").WizardCompleteBody => {
|
||||
const body: import("@agy/contracts").WizardCompleteBody = {
|
||||
org_name: form.org_name.trim(),
|
||||
slug: form.slug.trim(),
|
||||
app_name: form.app_name.trim(),
|
||||
app_type: form.app_type,
|
||||
language: form.language,
|
||||
framework: form.framework,
|
||||
runtime_version: form.runtime_version || null,
|
||||
package_manager: form.package_manager || null,
|
||||
setup_command: form.setup_command || null,
|
||||
build_command: form.build_command || null,
|
||||
test_command: form.test_command || null,
|
||||
start_command: form.start_command || null,
|
||||
app_port: form.app_port ? Number(form.app_port) : null,
|
||||
healthcheck_path: form.healthcheck_path || "/",
|
||||
git_source: form.git_source,
|
||||
};
|
||||
if (form.git_source === "external") {
|
||||
body.external_repo_url = form.external_repo_url.trim() || null;
|
||||
body.external_git_auth_type = form.external_git_auth_type;
|
||||
body.external_git_username = form.external_git_username || null;
|
||||
body.external_git_secret = form.external_git_secret || null;
|
||||
}
|
||||
if (form.deploy_kind) {
|
||||
body.deploy_target_kind = form.deploy_kind;
|
||||
body.deploy_target_host = form.deploy_host.trim();
|
||||
body.deploy_target_port = Number(form.deploy_port) || 22;
|
||||
body.deploy_target_username = form.deploy_username.trim();
|
||||
body.deploy_target_auth_type = form.deploy_auth_type;
|
||||
body.deploy_target_secret = form.deploy_secret || null;
|
||||
body.deploy_target_remote_path = form.deploy_remote_path || "/";
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await setupApi.complete(buildPayload());
|
||||
setResult(res);
|
||||
await refresh();
|
||||
if (res.organization?.id) setActiveOrganization(res.organization.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ----- guards / loading ---------------------------------------------------
|
||||
|
||||
if (!ready || loadingCatalog) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-8">
|
||||
<LoadingState />
|
||||
@@ -62,63 +352,477 @@ export default function OnboardingPage() {
|
||||
<Card className="anim-rise w-full max-w-md">
|
||||
<CardTitle>در انتظار دعوت</CardTitle>
|
||||
<CardHint>
|
||||
حساب شما در حال حاضر عضو هیچ سازمانی نیست و ساخت سازمان برای کاربران
|
||||
عادی فعال نیست. برای شروع کار، از مدیر سازمان خود بخواهید لینک دعوت
|
||||
برایتان بفرستد.
|
||||
حساب شما در حال حاضر عضو هیچ سازمانی نیست و ساخت سازمان برای کاربران عادی فعال
|
||||
نیست. برای شروع کار، از مدیر سازمان خود بخواهید لینک دعوت برایتان بفرستد.
|
||||
</CardHint>
|
||||
{organizations.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<Button className="w-full" onClick={() => router.replace("/dashboard")}>
|
||||
بازگشت به داشبورد
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-5">
|
||||
<Card className="anim-rise w-full max-w-md">
|
||||
<CardTitle>ساخت اولین سازمان</CardTitle>
|
||||
<Card className="anim-rise w-full max-w-md text-center">
|
||||
<CardTitle>سازمان ساخته شد</CardTitle>
|
||||
<CardHint>
|
||||
سازمان ظرف اصلی محصول است: اعضا، پروژهها، کارمندهای هوش مصنوعی و مصرف
|
||||
همه به سازمان تعلق دارند.
|
||||
«{result.organization.name}» و اپلیکیشن «{result.project.name}» آماده شدند.
|
||||
حالا میتوانید تیم هوشمصنوعی خود را راهبیندازید.
|
||||
</CardHint>
|
||||
|
||||
{organizations.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<p className="text-xs text-slate-400">سازمانهای فعلی شما:</p>
|
||||
{organizations.map((org) => (
|
||||
<button
|
||||
key={org.id}
|
||||
type="button"
|
||||
onClick={() => router.replace("/dashboard")}
|
||||
className="transition-colors rounded-lg border border-surface-border px-3 py-2 text-right text-xs text-slate-200 hover:bg-surface-overlay"
|
||||
>
|
||||
{org.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="my-2 border-t border-surface-border" />
|
||||
<div className="mt-5">
|
||||
<Button className="w-full" onClick={() => router.replace("/manager")}>
|
||||
برو به مدیر AI
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-4">
|
||||
// ----- step body ----------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-5">
|
||||
<Card className="anim-rise w-full max-w-xl">
|
||||
<Stepper step={step} />
|
||||
|
||||
{error && <div className="mt-4"><ErrorState title="خطا" message={error} /></div>}
|
||||
|
||||
<div className="mt-4">
|
||||
{step === 1 && (
|
||||
<OrgStep form={form} onOrgName={onOrgNameChange} onSlug={(v) => { setSlugTouched(true); update("slug", v); }} />
|
||||
)}
|
||||
{step === 2 && catalog && (
|
||||
<AppStep
|
||||
form={form}
|
||||
catalog={catalog}
|
||||
update={update}
|
||||
onFramework={applyFrameworkDefaults}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<GitStep
|
||||
form={form}
|
||||
update={update}
|
||||
gitCheck={gitCheck}
|
||||
gitChecking={gitChecking}
|
||||
onCheck={runGitCheck}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<ProductionStep
|
||||
form={form}
|
||||
update={update}
|
||||
deployCheck={deployCheck}
|
||||
deployChecking={deployChecking}
|
||||
onCheck={runDeployCheck}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && <ReviewStep form={form} catalog={catalog} language={selectedLanguage} framework={selectedFramework} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<Button variant="secondary" onClick={goBack} disabled={step === 1 || saving}>
|
||||
قبلی
|
||||
</Button>
|
||||
{step < 5 ? (
|
||||
<Button onClick={goNext} disabled={saving}>
|
||||
بعدی
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onSubmit} loading={saving} disabled={saving}>
|
||||
ساخت سازمان
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stepper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Stepper({ step }: { step: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CardTitle>راهاندازی سازمان</CardTitle>
|
||||
<ol className="flex items-center gap-1">
|
||||
{STEP_TITLES.map((title, idx) => {
|
||||
const n = idx + 1;
|
||||
const state = n === step ? "current" : n < step ? "done" : "todo";
|
||||
return (
|
||||
<li key={title} className="flex flex-1 items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs " +
|
||||
(state === "current"
|
||||
? "bg-accent text-white"
|
||||
: state === "done"
|
||||
? "bg-accent/30 text-accent"
|
||||
: "bg-surface-overlay text-slate-500")
|
||||
}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"hidden text-xs sm:inline " +
|
||||
(state === "todo" ? "text-slate-500" : "text-slate-200")
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{n < STEP_TITLES.length && <span className="h-px flex-1 bg-surface-border" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1 — Organization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OrgStep({
|
||||
form,
|
||||
onOrgName,
|
||||
onSlug,
|
||||
}: {
|
||||
form: WizardForm;
|
||||
onOrgName: (v: string) => void;
|
||||
onSlug: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardHint>سازمان ظرف اصلی محصول است. هر سازمان دقیقاً یک اپلیکیشن دارد.</CardHint>
|
||||
<Field
|
||||
label="نام سازمان"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={form.org_name}
|
||||
onChange={(e) => onOrgName(e.target.value)}
|
||||
placeholder="مثلاً شرکت نمونه"
|
||||
/>
|
||||
{error && <ErrorState title="ساخت سازمان ناموفق" message={error} />}
|
||||
<Button type="submit" loading={submitting}>
|
||||
ساخت سازمان
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
<Field
|
||||
label="نامک (slug)"
|
||||
required
|
||||
value={form.slug}
|
||||
onChange={(e) => onSlug(e.target.value)}
|
||||
hint="فقط حروف کوچک انگلیسی، عدد و خط تیره. در نشانی دمو استفاده میشود."
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2 — Application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AppStep({
|
||||
form,
|
||||
catalog,
|
||||
update,
|
||||
onFramework,
|
||||
}: {
|
||||
form: WizardForm;
|
||||
catalog: CatalogPayload;
|
||||
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
|
||||
onFramework: (fw: CatalogFramework) => void;
|
||||
}) {
|
||||
const [showAdvanced, setShowAdvanced] = React.useState(false);
|
||||
const language = catalog.languages.find((l) => l.key === form.language) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardHint>نوع، زبان و چارچوب اپلیکیشن را انتخاب کنید.</CardHint>
|
||||
<Field
|
||||
label="نام اپلیکیشن"
|
||||
required
|
||||
value={form.app_name}
|
||||
onChange={(e) => update("app_name", e.target.value)}
|
||||
placeholder="مثلاً پنل مشتریان"
|
||||
/>
|
||||
<Select
|
||||
label="نوع اپلیکیشن"
|
||||
value={form.app_type}
|
||||
onChange={(e) => update("app_type", e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "انتخاب کنید…" },
|
||||
...catalog.app_types.map((t) => ({
|
||||
value: t,
|
||||
label: catalog.app_type_labels[t] ?? t,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="زبان"
|
||||
value={form.language}
|
||||
onChange={(e) => {
|
||||
update("language", e.target.value);
|
||||
update("framework", "");
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "انتخاب کنید…" },
|
||||
...catalog.languages.map((l) => ({ value: l.key, label: l.display })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="چارچوب"
|
||||
value={form.framework}
|
||||
disabled={!language}
|
||||
onChange={(e) => {
|
||||
const fw = language?.frameworks.find((f) => f.key === e.target.value);
|
||||
if (fw) onFramework(fw);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: language ? "انتخاب کنید…" : "اول زبان را انتخاب کنید" },
|
||||
...(language?.frameworks.map((f) => ({ value: f.key, label: f.display })) ?? []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="self-start text-xs text-accent hover:underline"
|
||||
>
|
||||
{showAdvanced ? "بستن تنظیمات پیشرفته" : "تنظیمات پیشرفته (دستورات و پورت)"}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-surface-border p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="نسخه runtime" value={form.runtime_version} onChange={(e) => update("runtime_version", e.target.value)} className="ltr-inline" />
|
||||
<Field label="مدیر بسته" value={form.package_manager} onChange={(e) => update("package_manager", e.target.value)} className="ltr-inline" />
|
||||
</div>
|
||||
<Field label="دستور نصب" value={form.setup_command} onChange={(e) => update("setup_command", e.target.value)} className="ltr-inline" />
|
||||
<Field label="دستور ساخت" value={form.build_command} onChange={(e) => update("build_command", e.target.value)} className="ltr-inline" />
|
||||
<Field label="دستور تست" value={form.test_command} onChange={(e) => update("test_command", e.target.value)} className="ltr-inline" />
|
||||
<Field label="دستور اجرا" value={form.start_command} onChange={(e) => update("start_command", e.target.value)} className="ltr-inline" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="پورت اپلیکیشن" type="number" value={form.app_port} onChange={(e) => update("app_port", e.target.value)} className="ltr-inline" />
|
||||
<Field label="مسیر healthcheck" value={form.healthcheck_path} onChange={(e) => update("healthcheck_path", e.target.value)} className="ltr-inline" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3 — Git
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GitStep({
|
||||
form,
|
||||
update,
|
||||
gitCheck,
|
||||
gitChecking,
|
||||
onCheck,
|
||||
}: {
|
||||
form: WizardForm;
|
||||
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
|
||||
gitCheck: { ok: boolean; message?: string } | null;
|
||||
gitChecking: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardHint>مخزن گیت اپلیکیشن را انتخاب کنید. گیت را همیشه پلتفرم مدیریت میکند.</CardHint>
|
||||
<Select
|
||||
label="منبع گیت"
|
||||
value={form.git_source}
|
||||
onChange={(e) => update("git_source", e.target.value as WizardForm["git_source"])}
|
||||
options={[
|
||||
{ value: "platform", label: "Git پلتفرم (پیشنهادی)" },
|
||||
{ value: "external", label: "Git خودم" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{form.git_source === "platform" ? (
|
||||
<p className="rounded-lg border border-surface-border bg-surface-overlay p-3 text-xs text-slate-300">
|
||||
پلتفرم یک مخزن خصوصی برایتان میسازد و کلیدهای دسترسی را امن نگه میدارد. نیازی به
|
||||
تنظیم چیزی نیست.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field
|
||||
label="آدرس مخزن (HTTPS یا SSH)"
|
||||
required
|
||||
value={form.external_repo_url}
|
||||
onChange={(e) => update("external_repo_url", e.target.value)}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Select
|
||||
label="نوع احراز"
|
||||
value={form.external_git_auth_type}
|
||||
onChange={(e) => update("external_git_auth_type", e.target.value)}
|
||||
options={[
|
||||
{ value: "token", label: "توکن (access token)" },
|
||||
{ value: "ssh", label: "کلید SSH" },
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
label="نام کاربری (اختیاری)"
|
||||
value={form.external_git_username}
|
||||
onChange={(e) => update("external_git_username", e.target.value)}
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field
|
||||
label={form.external_git_auth_type === "ssh" ? "کلید خصوصی SSH" : "توکن / رمز"}
|
||||
type="password"
|
||||
required
|
||||
value={form.external_git_secret}
|
||||
onChange={(e) => update("external_git_secret", e.target.value)}
|
||||
hint="رمز فقط در درخواست نهایی فرستاده میشود و هرگز در پیشنویس ذخیره نمیگردد."
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<div>
|
||||
<Button variant="secondary" onClick={onCheck} loading={gitChecking}>
|
||||
بررسی اتصال
|
||||
</Button>
|
||||
</div>
|
||||
{gitCheck && (
|
||||
<p className={"text-xs " + (gitCheck.ok ? "text-success" : "text-danger")}>
|
||||
{gitCheck.ok ? "اتصال برقرار است." : gitCheck.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 4 — Production (optional)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProductionStep({
|
||||
form,
|
||||
update,
|
||||
deployCheck,
|
||||
deployChecking,
|
||||
onCheck,
|
||||
}: {
|
||||
form: WizardForm;
|
||||
update: <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => void;
|
||||
deployCheck: { ok: boolean; message?: string } | null;
|
||||
deployChecking: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const skip = !form.deploy_kind;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardHint>
|
||||
انتشار در تولید فقط وقتی انجام میشود که خودتان دکمه را بزنید. اینجا فقط تنظیمات را
|
||||
ثبت میکنید؛ هیچ کدی بدون کلیک شما به تولید نمیرود.
|
||||
</CardHint>
|
||||
|
||||
<Select
|
||||
label="نوع انتشار (اختیاری)"
|
||||
value={form.deploy_kind}
|
||||
onChange={(e) => update("deploy_kind", e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "رد کردن (فعلاً نمیخواهم)" },
|
||||
{ value: "ssh", label: "SSH" },
|
||||
{ value: "sftp", label: "SFTP" },
|
||||
{ value: "ftp", label: "FTP" },
|
||||
{ value: "ftps", label: "FTPS" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{!skip && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="میزبان" required value={form.deploy_host} onChange={(e) => update("deploy_host", e.target.value)} className="ltr-inline" />
|
||||
<Field label="پورت" type="number" value={form.deploy_port} onChange={(e) => update("deploy_port", e.target.value)} className="ltr-inline" />
|
||||
<Field label="نام کاربری" required value={form.deploy_username} onChange={(e) => update("deploy_username", e.target.value)} className="ltr-inline" />
|
||||
</div>
|
||||
<Select
|
||||
label="نوع احراز"
|
||||
value={form.deploy_auth_type}
|
||||
onChange={(e) => update("deploy_auth_type", e.target.value)}
|
||||
options={[
|
||||
{ value: "password", label: "رمز عبور" },
|
||||
{ value: "ssh_key", label: "کلید SSH" },
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
label={form.deploy_auth_type === "ssh_key" ? "کلید خصوصی SSH" : "رمز عبور"}
|
||||
type="password"
|
||||
required
|
||||
value={form.deploy_secret}
|
||||
onChange={(e) => update("deploy_secret", e.target.value)}
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field label="مسیر مقصد" value={form.deploy_remote_path} onChange={(e) => update("deploy_remote_path", e.target.value)} className="ltr-inline" />
|
||||
<div>
|
||||
<Button variant="secondary" onClick={onCheck} loading={deployChecking}>
|
||||
تست اتصال
|
||||
</Button>
|
||||
</div>
|
||||
{deployCheck && (
|
||||
<p className={"text-xs " + (deployCheck.ok ? "text-success" : "text-danger")}>
|
||||
{deployCheck.ok ? "اتصال برقرار است." : deployCheck.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 5 — Review
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReviewStep({
|
||||
form,
|
||||
catalog,
|
||||
language,
|
||||
framework,
|
||||
}: {
|
||||
form: WizardForm;
|
||||
catalog: CatalogPayload | null;
|
||||
language: CatalogLanguage | null;
|
||||
framework: CatalogFramework | null;
|
||||
}) {
|
||||
const typeLabel = form.app_type ? (catalog?.app_type_labels[form.app_type] ?? form.app_type) : "—";
|
||||
const rows: Array<[string, string]> = [
|
||||
["سازمان", form.org_name || "—"],
|
||||
["نامک", form.slug || "—"],
|
||||
["اپلیکیشن", form.app_name || "—"],
|
||||
["نوع", typeLabel],
|
||||
["زبان", language?.display ?? "—"],
|
||||
["چارچوب", framework?.display ?? "—"],
|
||||
["گیت", form.git_source === "platform" ? "Git پلتفرم" : "مخزن خودم"],
|
||||
];
|
||||
if (form.git_source === "external") {
|
||||
rows.push(["آدرس مخزن", form.external_repo_url || "—"]);
|
||||
rows.push(["رمز گیت", form.external_git_secret ? "•••••••• (ثبت میشود)" : "—"]);
|
||||
}
|
||||
rows.push(["تولید", form.deploy_kind ? form.deploy_kind.toUpperCase() : "رد شده"]);
|
||||
if (form.deploy_kind) {
|
||||
rows.push(["میزبان تولید", form.deploy_host || "—"]);
|
||||
rows.push(["رمز تولید", form.deploy_secret ? "•••••••• (ثبت میشود)" : "—"]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CardHint>همهچیز درست است؟ با زدن «ساخت سازمان» ثبت نهایی انجام میشود.</CardHint>
|
||||
<dl className="divide-y divide-surface-border rounded-lg border border-surface-border">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<dt className="text-xs text-slate-400">{k}</dt>
|
||||
<dd className="ltr-inline max-w-[60%] truncate text-xs text-slate-100">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,19 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
AppProfile,
|
||||
AppProfileUpdate,
|
||||
CatalogPayload,
|
||||
CheckDeployBody,
|
||||
CheckGitBody,
|
||||
DeployTargetPutBody,
|
||||
DeployTargetSummary,
|
||||
GitCredentialsPutBody,
|
||||
IssuedPlatformInvite,
|
||||
PlatformInvite,
|
||||
SetupDraft,
|
||||
WizardCompleteBody,
|
||||
WizardCompleteResult,
|
||||
} from "@agy/contracts";
|
||||
import { apiRequest } from "./api";
|
||||
|
||||
@@ -68,6 +79,71 @@ export const orgApi = {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* The new-organization wizard lives outside any org (the org does not exist
|
||||
* yet), so its endpoints are under `/setup/*` and take no org id. The wizard
|
||||
* stores a resumable draft server-side; secrets are only ever sent in the
|
||||
* final `complete` request and are never written to the draft.
|
||||
*/
|
||||
export const setupApi = {
|
||||
/** Catalog of app types, languages and framework defaults (v2 §2.1). */
|
||||
getCatalog: () => apiRequest<CatalogPayload>("/api/setup/catalog"),
|
||||
|
||||
/** Resume an in-progress draft (step + collected fields, no secrets). */
|
||||
getDraft: () => apiRequest<SetupDraft>("/api/setup/draft"),
|
||||
|
||||
/** Persist the current step + fields. Returns nothing (204). */
|
||||
saveDraft: (step: number, data: Record<string, unknown>) =>
|
||||
apiRequest<void>("/api/setup/draft", { method: "PUT", body: { step, data } }),
|
||||
|
||||
/** Probe an external git URL (format + DNS/SSRF check runs server-side). */
|
||||
checkGit: (body: CheckGitBody) =>
|
||||
apiRequest<{ ok: boolean; default_branch?: string; branches?: string[]; message?: string }>(
|
||||
"/api/setup/check-git",
|
||||
{ method: "POST", body },
|
||||
),
|
||||
|
||||
/** Probe a production deploy target connection (TOFU fingerprint on success). */
|
||||
checkDeploy: (body: CheckDeployBody) =>
|
||||
apiRequest<{ ok: boolean; message?: string; fingerprint?: string }>(
|
||||
"/api/setup/check-deploy",
|
||||
{ method: "POST", body },
|
||||
),
|
||||
|
||||
/** Create the org + single app + optional git/deploy in one transaction. */
|
||||
complete: (body: WizardCompleteBody) =>
|
||||
apiRequest<WizardCompleteResult>("/api/setup/complete", { method: "POST", body }),
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Org-scoped application profile (the single app of the org, v2 D1/D2). Owner
|
||||
* and admin may edit; secrets are write-only and come back only as `has_secret`.
|
||||
*/
|
||||
export const appProfileApi = {
|
||||
get: (orgId: string) =>
|
||||
apiRequest<AppProfile>(`/api/organizations/${orgId}/app/profile`),
|
||||
|
||||
update: (orgId: string, values: AppProfileUpdate) =>
|
||||
apiRequest<AppProfile>(`/api/organizations/${orgId}/app/profile`, {
|
||||
method: "PATCH",
|
||||
body: values,
|
||||
}),
|
||||
|
||||
putDeployTarget: (orgId: string, body: DeployTargetPutBody) =>
|
||||
apiRequest<DeployTargetSummary>(`/api/organizations/${orgId}/app/deploy-target`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
}),
|
||||
|
||||
putGitCredentials: (orgId: string, body: GitCredentialsPutBody) =>
|
||||
apiRequest<{ git_source: string; has_secret: boolean }>(
|
||||
`/api/organizations/${orgId}/app/git`,
|
||||
{ method: "PUT", body },
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* True when the signed-in account still needs the creation wizard: a manager
|
||||
* with no organization yet (v2 D12). The owner is excluded by the server.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* §v2-A1.2 — the 5-step organization-creation wizard (frontend §2.7).
|
||||
*
|
||||
* The wizard is the only path that creates an org + single app, so the tests
|
||||
* focus on what would silently corrupt the product: the catalog loading, step
|
||||
* navigation, and — critically — that a typed git/deploy secret is never
|
||||
* written into the resumable draft payload (only the final submit carries it).
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
session: {
|
||||
ready: true,
|
||||
user: { id: "u1", email: "m@e.com", platform_role: "manager" } as Record<string, unknown>,
|
||||
organizations: [] as Array<Record<string, unknown>>,
|
||||
activeOrganization: null as Record<string, unknown> | null,
|
||||
login: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
setActiveOrganization: vi.fn(),
|
||||
},
|
||||
setupApi: {
|
||||
getCatalog: vi.fn(),
|
||||
getDraft: vi.fn(),
|
||||
saveDraft: vi.fn((_step: number, _data: Record<string, unknown>) => Promise.resolve()),
|
||||
checkGit: vi.fn(async () => ({ ok: true })),
|
||||
checkDeploy: vi.fn(async () => ({ ok: true })),
|
||||
complete: vi.fn(async () => ({
|
||||
organization: { id: "org-1", name: "Acme", slug: "acme" },
|
||||
project: { id: "p-1", name: "app", slug: "app" },
|
||||
deploy_target_id: null,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-org", () => ({ setupApi: mocks.setupApi }));
|
||||
|
||||
const replace = vi.fn();
|
||||
Object.assign(mocks, { replace });
|
||||
|
||||
import OnboardingPage from "@/app/onboarding/page";
|
||||
|
||||
const CATALOG = {
|
||||
app_types: ["web_app"],
|
||||
app_type_labels: { web_app: "وباپ" },
|
||||
git_sources: ["platform", "external"],
|
||||
languages: [
|
||||
{
|
||||
key: "node",
|
||||
display: "Node.js",
|
||||
frameworks: [
|
||||
{
|
||||
key: "next",
|
||||
display: "Next.js",
|
||||
app_type: "web_app",
|
||||
defaults: {
|
||||
runtime_version: "20",
|
||||
package_manager: "npm",
|
||||
setup_command: "npm ci",
|
||||
build_command: "npm run build",
|
||||
test_command: "npm test",
|
||||
start_command: "npm start",
|
||||
app_port: 3000,
|
||||
healthcheck_path: "/",
|
||||
docker_image: "node:20",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.session.ready = true;
|
||||
mocks.session.user = { id: "u1", email: "m@e.com", platform_role: "manager" };
|
||||
mocks.session.organizations = [];
|
||||
mocks.session.activeOrganization = null;
|
||||
mocks.setupApi.getCatalog.mockResolvedValue(CATALOG);
|
||||
mocks.setupApi.getDraft.mockResolvedValue({ step: 1, data: {} });
|
||||
});
|
||||
|
||||
describe("the organization wizard", () => {
|
||||
it("loads the catalog and opens on step 1 (سازمان)", async () => {
|
||||
render(<OnboardingPage />);
|
||||
await waitFor(() => expect(screen.getByText("نام سازمان")).toBeInTheDocument());
|
||||
expect(mocks.setupApi.getCatalog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-suggests a slug from the org name", async () => {
|
||||
render(<OnboardingPage />);
|
||||
const name = await screen.findByLabelText("نام سازمان");
|
||||
fireEvent.change(name, { target: { value: "Acme Corp" } });
|
||||
const slug = screen.getByLabelText("نامک (slug)") as HTMLInputElement;
|
||||
expect(slug.value).toBe("acme-corp");
|
||||
});
|
||||
|
||||
it("moves to step 2 (اپلیکیشن) on next", async () => {
|
||||
render(<OnboardingPage />);
|
||||
const name = await screen.findByLabelText("نام سازمان");
|
||||
fireEvent.change(name, { target: { value: "Acme" } });
|
||||
fireEvent.click(screen.getByText("بعدی"));
|
||||
await waitFor(() => expect(screen.getByText("نوع اپلیکیشن")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("never writes the git secret into the draft payload", async () => {
|
||||
// Resume at step 3 (git) with an external repo but no secret saved yet.
|
||||
mocks.setupApi.getDraft.mockResolvedValue({
|
||||
step: 3,
|
||||
data: { git_source: "external", external_repo_url: "https://example.com/x.git" },
|
||||
});
|
||||
render(<OnboardingPage />);
|
||||
|
||||
const secret = await screen.findByLabelText("توکن / رمز");
|
||||
fireEvent.change(secret, { target: { value: "supersecret-token-123" } });
|
||||
|
||||
// Advance to the production step — this triggers a draft auto-save.
|
||||
fireEvent.click(screen.getByText("بعدی"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.setupApi.saveDraft).toHaveBeenCalled(),
|
||||
);
|
||||
|
||||
const lastCall = mocks.setupApi.saveDraft.mock.calls.at(-1)!;
|
||||
const payload = lastCall[1] as Record<string, unknown>;
|
||||
// The draft carries the non-secret repo url...
|
||||
expect(payload).toMatchObject({ external_repo_url: "https://example.com/x.git" });
|
||||
// ...but never the secret itself.
|
||||
expect(payload).not.toHaveProperty("external_git_secret");
|
||||
expect(JSON.stringify(payload)).not.toContain("supersecret-token-123");
|
||||
});
|
||||
|
||||
it("bounces a manager who already owns an organization to the dashboard", async () => {
|
||||
mocks.session.organizations = [{ id: "org-x", name: "X", slug: "x" }];
|
||||
render(<OnboardingPage />);
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith("/dashboard"));
|
||||
});
|
||||
|
||||
it("shows the waiting screen for a plain user who cannot create orgs", async () => {
|
||||
mocks.session.user = { id: "u2", email: "p@e.com", platform_role: "user" };
|
||||
render(<OnboardingPage />);
|
||||
await waitFor(() => expect(screen.getByText("در انتظار دعوت")).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -1093,3 +1093,159 @@ export interface WsDeployStatusData {
|
||||
status: ProductionDeployStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/* -- org wizard (v2 A1-2) ------------------------------------------------- */
|
||||
|
||||
export interface CatalogPayload {
|
||||
app_types: string[];
|
||||
app_type_labels: Record<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"
|
||||
>
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user