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