diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..1d02fc2 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,44 @@ +name: Python application + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + service: [auth-service, engine] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install dependencies + working-directory: ./${{ matrix.service }} + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Lint with flake8 + working-directory: ./${{ matrix.service }} + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Test with pytest + working-directory: ./${{ matrix.service }} + run: | + pytest diff --git a/README.ja.md b/README.ja.md index 330f398..03e7ecb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,8 @@ -# Dynaman - 動的スキーマ PoC +# Dynaman - Dynamic Schema PoC -[Read in English](README.md) +[![Python application](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml/badge.svg)](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml) + +[English](README.md) **Dynaman** は、現代の **NoCode/LowCode プラットフォーム** における中心的なパターンである **未固定かつ動的なスキーマ** のアーキテクチャと実装を実証するために設計された Proof of Concept (PoC) プロジェクトです。 @@ -31,6 +33,8 @@ ### インフラストラクチャ * **コンテナ化**: Docker & Docker Compose * **ゲートウェイ**: Nginx (リバースプロキシ) +* **IaC**: Terraform (Infrastructure as Code) +* **クラウドプロバイダー**: AWS (ECS Fargate/EC2, ALB, ECR, CodePipeline) ### フロントエンド (`/dynaman-ui`) * **フレームワーク**: [React](https://react.dev/) (with Vite) @@ -81,6 +85,10 @@ dynaman/ │ │ ├── pages/ # アプリケーションビュー │ │ └── context/ # [NEW] AuthContext │ +├── infrastructure/ # [NEW] Terraform & AWS 設定 +│ ├── terraform/ # IaC 定義 +│ └── README.md # デプロイガイド +│ ├── docker-compose.yml # [NEW] コンテナオーケストレーション └── nginx-gateway.conf # [NEW] API ゲートウェイ設定 ``` diff --git a/README.md b/README.md index fd7bc80..b9d9e8d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Dynaman - Dynamic Schema PoC +[![Python application](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml/badge.svg)](https://github.com/sanlinnaing/dynaman/actions/workflows/python-app.yml) + [日本語で読む](README.ja.md) **Dynaman** is a Proof of Concept (PoC) project designed to demonstrate the architecture and implementation of **unfixed, dynamic schemas**—a core pattern found in modern **NoCode/LowCode platforms**. @@ -31,6 +33,8 @@ The primary goal is to showcase how to handle **User-Defined Requirements** wher ### Infrastructure * **Containerization**: Docker & Docker Compose * **Gateway**: Nginx (Reverse Proxy) +* **IaC**: Terraform (Infrastructure as Code) +* **Cloud Provider**: AWS (ECS Fargate/EC2, ALB, ECR, CodePipeline) ### Frontend (`/dynaman-ui`) * **Framework**: [React](https://react.dev/) (with Vite) @@ -81,6 +85,10 @@ dynaman/ │ │ ├── pages/ # Application Views │ │ └── context/ # [NEW] AuthContext │ +├── infrastructure/ # [NEW] Terraform & AWS Configuration +│ ├── terraform/ # IaC Definitions +│ └── README.md # Deployment Guide +│ ├── docker-compose.yml # [NEW] Container Orchestration └── nginx-gateway.conf # [NEW] API Gateway Config ``` diff --git a/auth-service/api/dependencies.py b/auth-service/api/dependencies.py index 774854c..9db7d08 100644 --- a/auth-service/api/dependencies.py +++ b/auth-service/api/dependencies.py @@ -1,6 +1,7 @@ from motor.motor_asyncio import AsyncIOMotorClient from config import settings from infrastructure.user_repository import UserRepository +from infrastructure.user_group_repository import UserGroupRepository from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError @@ -17,6 +18,9 @@ async def get_db(): async def get_user_repository(db = Depends(get_db)): return UserRepository(db) +async def get_user_group_repository(db = Depends(get_db)): + return UserGroupRepository(db) + async def get_current_user( token: str = Depends(oauth2_scheme), user_repo: UserRepository = Depends(get_user_repository) diff --git a/auth-service/api/v1/router_auth.py b/auth-service/api/v1/router_auth.py index 9d18a47..2abb7c8 100644 --- a/auth-service/api/v1/router_auth.py +++ b/auth-service/api/v1/router_auth.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from domain.entities.user import User, UserCreate, UserRole +from domain.entities.user import User, UserCreate, UserUpdate, UserRole from application.auth_use_cases import AuthUseCases from infrastructure.user_repository import UserRepository from api.dependencies import get_user_repository, get_current_user @@ -48,6 +48,37 @@ async def list_users( use_cases = AuthUseCases(user_repo) return await use_cases.list_users() +@router.put("/users/{user_id}", response_model=User) +async def update_user( + user_id: str, + user_in: UserUpdate, + user_repo: Annotated[UserRepository, Depends(get_user_repository)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.role == UserRole.USER: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update users" + ) + + use_cases = AuthUseCases(user_repo) + target_user = await user_repo.get_by_id(user_id) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # Permission checks + if current_user.role == UserRole.USER_ADMIN: + if target_user.role == UserRole.SYSTEM_ADMIN: + raise HTTPException(status_code=403, detail="User Admins cannot update System Admins") + if user_in.role == UserRole.SYSTEM_ADMIN: + raise HTTPException(status_code=403, detail="User Admins cannot promote to System Admin") + + updated_user = await use_cases.update_user(user_id, user_in) + if not updated_user: + raise HTTPException(status_code=404, detail="User not found") + + return updated_user + @router.delete("/users/{user_id}") async def delete_user( user_id: str, @@ -90,5 +121,5 @@ async def login( detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) - access_token = SecurityService.create_access_token(data={"sub": user.email, "role": user.role}) + access_token = SecurityService.create_access_token(data={"sub": user.email, "role": user.role, "groups": user.group_ids}) return {"access_token": access_token, "token_type": "bearer"} diff --git a/auth-service/api/v1/router_groups.py b/auth-service/api/v1/router_groups.py new file mode 100644 index 0000000..6d81361 --- /dev/null +++ b/auth-service/api/v1/router_groups.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from typing import Annotated +from domain.entities.user import User, UserRole +from domain.entities.user_group import UserGroup, UserGroupCreate, UserGroupUpdate +from infrastructure.user_group_repository import UserGroupRepository +from api.dependencies import get_user_group_repository, get_current_user + +router = APIRouter() + +def require_system_admin(current_user: User = Depends(get_current_user)): + if current_user.role != UserRole.SYSTEM_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Requires System Admin privileges" + ) + return current_user + +@router.post("", response_model=UserGroup, status_code=status.HTTP_201_CREATED) +async def create_group( + group_in: UserGroupCreate, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + existing = await repo.get_by_name(group_in.name) + if existing: + raise HTTPException(status_code=400, detail="Group with this name already exists") + + group = UserGroup(**group_in.model_dump()) + return await repo.create(group) + +@router.get("", response_model=list[UserGroup]) +async def list_groups( + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + return await repo.get_all() + +@router.get("/{group_id}", response_model=UserGroup) +async def get_group( + group_id: str, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + group = await repo.get_by_id(group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + return group + +@router.put("/{group_id}", response_model=UserGroup) +async def update_group( + group_id: str, + group_in: UserGroupUpdate, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + updated_group = await repo.update(group_id, group_in.model_dump(exclude_unset=True)) + if not updated_group: + raise HTTPException(status_code=404, detail="Group not found") + return updated_group + +@router.delete("/{group_id}") +async def delete_group( + group_id: str, + repo: Annotated[UserGroupRepository, Depends(get_user_group_repository)], + _: Annotated[User, Depends(require_system_admin)] +): + success = await repo.delete(group_id) + if not success: + raise HTTPException(status_code=404, detail="Group not found") + return {"message": "Group deleted successfully"} diff --git a/auth-service/application/auth_use_cases.py b/auth-service/application/auth_use_cases.py index 003dd7d..01ff9b4 100644 --- a/auth-service/application/auth_use_cases.py +++ b/auth-service/application/auth_use_cases.py @@ -1,4 +1,4 @@ -from domain.entities.user import User, UserCreate +from domain.entities.user import User, UserCreate, UserUpdate from domain.services.security_service import SecurityService from infrastructure.user_repository import UserRepository from fastapi import HTTPException, status @@ -19,7 +19,8 @@ async def register_user(self, user_in: UserCreate): new_user = User( email=user_in.email, hashed_password=hashed_password, - role=user_in.role + role=user_in.role, + group_ids=user_in.group_ids ) created_user = await self.user_repo.create(new_user) return created_user @@ -35,5 +36,14 @@ async def authenticate_user(self, email: str, password: str): async def list_users(self): return await self.user_repo.get_all() + async def update_user(self, user_id: str, user_in: UserUpdate): + update_data = user_in.model_dump(exclude_unset=True) + if "password" in update_data: + hashed_password = SecurityService.get_password_hash(update_data["password"]) + update_data["hashed_password"] = hashed_password + del update_data["password"] + + return await self.user_repo.update(user_id, update_data) + async def delete_user(self, user_id: str): return await self.user_repo.delete(user_id) diff --git a/auth-service/domain/entities/user.py b/auth-service/domain/entities/user.py index 65d02e9..98d95af 100644 --- a/auth-service/domain/entities/user.py +++ b/auth-service/domain/entities/user.py @@ -17,6 +17,7 @@ class User(BaseModel): hashed_password: str is_active: bool = True role: UserRole = UserRole.USER + group_ids: list[str] = Field(default_factory=list, description="List of UserGroup IDs this user belongs to") provider: str = "local" # local, google, etc. model_config = ConfigDict( @@ -29,3 +30,11 @@ class UserCreate(BaseModel): email: EmailStr password: str role: UserRole = UserRole.USER + group_ids: list[str] = Field(default_factory=list) + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + password: Optional[str] = None + is_active: Optional[bool] = None + role: Optional[UserRole] = None + group_ids: Optional[list[str]] = None \ No newline at end of file diff --git a/auth-service/domain/entities/user_group.py b/auth-service/domain/entities/user_group.py new file mode 100644 index 0000000..462efba --- /dev/null +++ b/auth-service/domain/entities/user_group.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field, BeforeValidator, ConfigDict +from typing import Optional, Annotated +from bson import ObjectId + +# Helper for Pydantic v2 + BSON +PyObjectId = Annotated[str, BeforeValidator(str)] + +class UserGroup(BaseModel): + id: Optional[PyObjectId] = Field(alias="_id", default=None) + name: str = Field(..., min_length=1, description="Unique name of the group (e.g., 'Sales')") + description: Optional[str] = Field(default=None, description="Description of the group's purpose") + + model_config = ConfigDict( + populate_by_name=True, + arbitrary_types_allowed=True, + json_encoders={ObjectId: str} + ) + +class UserGroupCreate(BaseModel): + name: str + description: Optional[str] = None + +class UserGroupUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None diff --git a/auth-service/infrastructure/user_group_repository.py b/auth-service/infrastructure/user_group_repository.py new file mode 100644 index 0000000..3ee3ef8 --- /dev/null +++ b/auth-service/infrastructure/user_group_repository.py @@ -0,0 +1,65 @@ +from motor.motor_asyncio import AsyncIOMotorDatabase +from domain.entities.user_group import UserGroup +from bson import ObjectId + +class UserGroupRepository: + def __init__(self, db: AsyncIOMotorDatabase): + self.collection = db["user_groups"] + + async def get_by_id(self, group_id: str): + try: + oid = ObjectId(group_id) + except Exception: + return None + doc = await self.collection.find_one({"_id": oid}) + if doc: + return UserGroup(**doc) + return None + + async def get_by_name(self, name: str): + doc = await self.collection.find_one({"name": name}) + if doc: + return UserGroup(**doc) + return None + + async def get_all(self): + cursor = self.collection.find() + groups = [] + async for doc in cursor: + groups.append(UserGroup(**doc)) + return groups + + async def create(self, group: UserGroup): + group_dict = group.model_dump(by_alias=True, exclude={"id"}) + result = await self.collection.insert_one(group_dict) + group.id = str(result.inserted_id) + return group + + async def update(self, group_id: str, update_data: dict): + # update_data should match UserGroupUpdate fields + if not update_data: + return None + + try: + oid = ObjectId(group_id) + except Exception: + return None + + result = await self.collection.update_one( + {"_id": oid}, + {"$set": update_data} + ) + if result.modified_count > 0: + return await self.get_by_id(group_id) + # If no document matched, return None. If matched but no change, fetch and return. + if result.matched_count > 0: + return await self.get_by_id(group_id) + return None + + async def delete(self, group_id: str): + try: + oid = ObjectId(group_id) + except Exception: + return False + result = await self.collection.delete_one({"_id": oid}) + return result.deleted_count > 0 diff --git a/auth-service/infrastructure/user_repository.py b/auth-service/infrastructure/user_repository.py index 0c3468d..88f54c8 100644 --- a/auth-service/infrastructure/user_repository.py +++ b/auth-service/infrastructure/user_repository.py @@ -35,6 +35,22 @@ async def get_all(self): users.append(User(**doc)) return users + async def update(self, user_id: str, update_data: dict): + if not update_data: + return None + try: + oid = ObjectId(user_id) + except Exception: + return None + + result = await self.collection.update_one( + {"_id": oid}, + {"$set": update_data} + ) + if result.modified_count > 0 or result.matched_count > 0: + return await self.get_by_id(user_id) + return None + async def delete(self, user_id: str): result = await self.collection.delete_one({"_id": ObjectId(user_id)}) return result.deleted_count > 0 diff --git a/auth-service/main.py b/auth-service/main.py index 8987e8c..eb964ba 100644 --- a/auth-service/main.py +++ b/auth-service/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.v1.router_auth import router as auth_router +from api.v1.router_groups import router as groups_router from contextlib import asynccontextmanager from api.dependencies import get_user_repository, get_db from domain.entities.user import User, UserRole @@ -52,3 +53,4 @@ async def health_check(): return {"status": "ok"} app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"]) +app.include_router(groups_router, prefix="/api/v1/groups", tags=["groups"]) diff --git a/auth-service/pytest.ini b/auth-service/pytest.ini index fd2bcbd..013c77c 100644 --- a/auth-service/pytest.ini +++ b/auth-service/pytest.ini @@ -1,4 +1,6 @@ [pytest] pythonpath = . testpaths = tests -addopts = --cov=. --cov-report=term-missing +python_functions = test_* +asyncio_mode = auto +addopts = -v --cov=. --cov-report=term-missing diff --git a/auth-service/requirements.txt b/auth-service/requirements.txt index 4510c58..a39ae7d 100644 --- a/auth-service/requirements.txt +++ b/auth-service/requirements.txt @@ -8,5 +8,9 @@ bcrypt==4.0.1 python-multipart email-validator pytest +pytest-asyncio httpx pytest-cov +flake8 +mongomock +mongomock-motor diff --git a/auth-service/tests/test_user_group_repository.py b/auth-service/tests/test_user_group_repository.py new file mode 100644 index 0000000..11fc7fd --- /dev/null +++ b/auth-service/tests/test_user_group_repository.py @@ -0,0 +1,82 @@ +import pytest +from infrastructure.user_group_repository import UserGroupRepository +from domain.entities.user_group import UserGroup +from mongomock_motor import AsyncMongoMockClient +from bson import ObjectId + +@pytest.fixture +async def mock_db(): + client = AsyncMongoMockClient() + db = client["test_db"] + return db + +@pytest.fixture +async def repo(mock_db): + return UserGroupRepository(mock_db) + +@pytest.mark.asyncio +async def test_create_and_get_group(repo): + group = UserGroup(name="Sales", description="Sales Team") + created = await repo.create(group) + assert created.id is not None + + fetched = await repo.get_by_id(created.id) + assert fetched.name == "Sales" + assert fetched.description == "Sales Team" + +@pytest.mark.asyncio +async def test_get_by_name(repo): + await repo.create(UserGroup(name="Marketing")) + + group = await repo.get_by_name("Marketing") + assert group is not None + assert group.name == "Marketing" + + none_group = await repo.get_by_name("NonExistent") + assert none_group is None + +@pytest.mark.asyncio +async def test_update_group(repo): + group = await repo.create(UserGroup(name="Dev")) + + updated = await repo.update(group.id, {"name": "Engineering", "description": "Tech"}) + assert updated.name == "Engineering" + assert updated.description == "Tech" + + # Verify persistence + fetched = await repo.get_by_id(group.id) + assert fetched.name == "Engineering" + + # Update non-existent + result = await repo.update(str(ObjectId()), {"name": "Ghost"}) + assert result is None + +@pytest.mark.asyncio +async def test_delete_group(repo): + group = await repo.create(UserGroup(name="Temp")) + + success = await repo.delete(group.id) + assert success is True + + fetched = await repo.get_by_id(group.id) + assert fetched is None + + # Delete non-existent + success = await repo.delete(str(ObjectId())) + assert success is False + +@pytest.mark.asyncio +async def test_get_all(repo): + await repo.create(UserGroup(name="A")) + await repo.create(UserGroup(name="B")) + + groups = await repo.get_all() + assert len(groups) == 2 + names = sorted([g.name for g in groups]) + assert names == ["A", "B"] + +@pytest.mark.asyncio +async def test_invalid_id_handling(repo): + assert await repo.get_by_id("invalid-oid") is None + assert await repo.update("invalid-oid", {}) is None + assert await repo.delete("invalid-oid") is False diff --git a/auth-service/tests/test_user_groups.py b/auth-service/tests/test_user_groups.py new file mode 100644 index 0000000..9d16f95 --- /dev/null +++ b/auth-service/tests/test_user_groups.py @@ -0,0 +1,124 @@ +import pytest +from httpx import AsyncClient +from unittest.mock import AsyncMock +from domain.entities.user import User, UserRole +from domain.entities.user_group import UserGroup +from api.dependencies import get_user_group_repository, get_current_user +from main import app + +@pytest.fixture +def mock_group_repo(): + return AsyncMock() + +@pytest.fixture +def system_admin_user(): + return User( + email="admin@example.com", + hashed_password="hash", + role=UserRole.SYSTEM_ADMIN, + group_ids=[] + ) + +@pytest.mark.asyncio +async def test_create_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.get_by_name.return_value = None + # Simulate create returning the object + async def create_side_effect(group): + group.id = "mock_id" + return group + mock_group_repo.create.side_effect = create_side_effect + + response = await client.post("/api/v1/groups", json={ + "name": "Sales", + "description": "Sales Team" + }) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Sales" + assert data["description"] == "Sales Team" + assert "_id" in data # Aliased id + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_list_groups(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.get_all.return_value = [ + UserGroup(id="1", name="G1"), + UserGroup(id="2", name="G2") + ] + + response = await client.get("/api/v1/groups") + assert response.status_code == 200 + assert len(response.json()) == 2 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_get_group_by_id(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group = UserGroup(id="123", name="G1") + mock_group_repo.get_by_id.return_value = mock_group + + response = await client.get("/api/v1/groups/123") + assert response.status_code == 200 + assert response.json()["name"] == "G1" + + mock_group_repo.get_by_id.return_value = None + response = await client.get("/api/v1/groups/999") + assert response.status_code == 404 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_update_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + updated_group = UserGroup(id="123", name="Updated Name") + mock_group_repo.update.return_value = updated_group + + response = await client.put("/api/v1/groups/123", json={"name": "Updated Name"}) + assert response.status_code == 200 + assert response.json()["name"] == "Updated Name" + + mock_group_repo.update.return_value = None + response = await client.put("/api/v1/groups/999", json={"name": "Ghost"}) + assert response.status_code == 404 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_delete_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.delete.return_value = True + response = await client.delete("/api/v1/groups/123") + assert response.status_code == 200 + + mock_group_repo.delete.return_value = False + response = await client.delete("/api/v1/groups/999") + assert response.status_code == 404 + + app.dependency_overrides.clear() + +@pytest.mark.asyncio +async def test_create_duplicate_group(client, mock_group_repo, system_admin_user): + app.dependency_overrides[get_user_group_repository] = lambda: mock_group_repo + app.dependency_overrides[get_current_user] = lambda: system_admin_user + + mock_group_repo.get_by_name.return_value = UserGroup(id="1", name="Sales") + + response = await client.post("/api/v1/groups", json={"name": "Sales"}) + assert response.status_code == 400 + + app.dependency_overrides.clear() diff --git a/dynaman-ui/package-lock.json b/dynaman-ui/package-lock.json index 55299b8..16b7a30 100644 --- a/dynaman-ui/package-lock.json +++ b/dynaman-ui/package-lock.json @@ -8,6 +8,9 @@ "name": "dynaman-ui", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-slot": "^1.1.1", "@tanstack/react-table": "^8.20.6", @@ -15,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.471.0", + "nanoid": "^5.1.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.1.1", @@ -334,6 +338,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -3467,10 +3525,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -3479,10 +3536,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -3632,6 +3689,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3823,6 +3881,25 @@ "dev": true, "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4323,6 +4400,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/dynaman-ui/package.json b/dynaman-ui/package.json index 4838c99..a3603a2 100644 --- a/dynaman-ui/package.json +++ b/dynaman-ui/package.json @@ -10,17 +10,21 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.2.0", - "react-dom": "^19.2.0", - "lucide-react": "^0.471.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-slot": "^1.1.1", "@tanstack/react-table": "^8.20.6", "axios": "^1.7.9", - "clsx": "^2.1.1", - "tailwind-merge": "^2.6.0", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.471.0", + "nanoid": "^5.1.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", "react-router-dom": "^7.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-label": "^2.1.1" + "tailwind-merge": "^2.6.0" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -28,15 +32,15 @@ "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.20", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4", - "tailwindcss": "^3.4.17", - "postcss": "^8.5.1", - "autoprefixer": "^10.4.20" + "vite": "^7.2.4" } } diff --git a/dynaman-ui/src/App.tsx b/dynaman-ui/src/App.tsx index 206e07c..980bff4 100644 --- a/dynaman-ui/src/App.tsx +++ b/dynaman-ui/src/App.tsx @@ -3,11 +3,14 @@ import DashboardLayout from '@/components/Layout'; import Home from '@/pages/Home'; import DataExplorer from '@/pages/DataExplorer'; import SchemaEditor from '@/pages/SchemaEditor'; +import SchemaListPage from '@/pages/SchemaListPage'; import { Login } from '@/pages/Login'; import { AuthProvider } from '@/context/AuthContext'; import { RequireAuth } from '@/components/RequireAuth'; import { RequireSystemAdmin } from '@/components/RequireSystemAdmin'; import { AdminUsers } from '@/pages/AdminUsers'; +import AdminGroups from '@/pages/AdminGroups'; +import LayoutDesigner from '@/pages/LayoutDesigner'; import './App.css'; function App() { @@ -23,8 +26,15 @@ function App() { }> } /> + + } /> } /> + + + + } /> @@ -35,8 +45,18 @@ function App() { } /> + + + + } /> } /> + + + + } /> 404 - Page Not Found} /> diff --git a/dynaman-ui/src/components/DynamicForm.tsx b/dynaman-ui/src/components/DynamicForm.tsx new file mode 100644 index 0000000..76be6c6 --- /dev/null +++ b/dynaman-ui/src/components/DynamicForm.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from 'react'; +import { layoutApi, type FormLayout } from '@/lib/api'; +import DataInputForm from './DataInputForm'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import api from '@/lib/api'; +import { useLanguage } from '@/lib/i18n'; + +// Match DataInputForm props +interface DynamicFormProps { + schemaName: string; // Extra prop we need + schema: any; + isOpen: boolean; + onClose: () => void; + onSave: () => void; + recordId?: string; + initialData?: any; +} + +interface LayoutItem { + id: string; + type: 'field' | 'structure'; + label: string; + fieldName?: string; + fieldType?: string; + structureType?: string; + children?: LayoutItem[]; +} + +const LayoutRenderer = ({ + items, + formData, + onChange +}: { + items: LayoutItem[], + formData: any, + onChange: (field: string, val: any) => void +}) => { + if (!items) return null; + + return ( +
+ {items.map(item => { + if (item.type === 'field' && item.fieldName) { + return ( +
+ + {item.fieldType === 'boolean' ? ( +
+ onChange(item.fieldName!, e.target.checked)} + /> + Yes +
+ ) : item.fieldType === 'number' ? ( + onChange(item.fieldName!, Number(e.target.value))} + placeholder={`Enter ${item.label}`} + /> + ) : item.fieldType === 'date' ? ( + onChange(item.fieldName!, e.target.value)} + /> + ) : ( + onChange(item.fieldName!, e.target.value)} + placeholder={`Enter ${item.label}`} + /> + )} +
+ ); + } + // Placeholder for structure + if (item.type === 'structure') { + return ( +
+

{item.label}

+ +
+ ) + } + return null; + })} +
+ ); +}; + +export const DynamicForm: React.FC = (props) => { + const { schemaName, recordId, initialData } = props; + const [layout, setLayout] = useState(null); + const [loading, setLoading] = useState(true); + const [formData, setFormData] = useState({}); + const [error, setError] = useState(null); + const { t } = useLanguage(); + + useEffect(() => { + if (!props.isOpen) return; + + // Reset form data + if (initialData) { + setFormData(initialData); + } else { + setFormData({}); + } + + const fetchLayout = async () => { + setLoading(true); + const resolvedLayout = await layoutApi.resolve(schemaName); + setLayout(resolvedLayout); + setLoading(false); + }; + fetchLayout(); + }, [schemaName, props.isOpen, initialData]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + if (recordId) { + await api.put(`/api/v1/data/${schemaName}/${recordId}`, formData); + } else { + await api.post(`/api/v1/data/${schemaName}`, formData); + } + props.onSave(); + } catch (err: any) { + console.error(err); + setError("Failed to save data"); + } finally { + setLoading(false); + } + }; + + if (loading && props.isOpen && !layout) { + return
Loading layout...
; + } + + // If we have a custom layout definition, use our custom renderer + if (layout && layout.definition && layout.definition.length > 0) { + return ( +
+
+
+

+ {recordId ? t('form.editTitle', { entity: schemaName }) : t('form.createTitle', { entity: schemaName })} +

+ {layout.name} +
+ + {error &&
{error}
} + +
+ setFormData((prev: any) => ({ ...prev, [field]: val }))} + /> + +
+ + +
+ +
+
+ ); + } + + // Fallback to default DataInputForm + return ( + + ); +}; \ No newline at end of file diff --git a/dynaman-ui/src/components/Layout.tsx b/dynaman-ui/src/components/Layout.tsx index e976d94..144b5ed 100644 --- a/dynaman-ui/src/components/Layout.tsx +++ b/dynaman-ui/src/components/Layout.tsx @@ -1,29 +1,60 @@ import { useEffect, useState } from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { Table as TableIcon, LayoutDashboard, Menu, PlusCircle, Globe, LogOut, Users } from 'lucide-react'; -import api from '@/lib/api'; +import { Table as TableIcon, LayoutDashboard, Menu, Globe, LogOut, Users, History, Database } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useLanguage } from '@/lib/i18n'; import { useAuth } from '@/context/AuthContext'; export default function DashboardLayout() { - const [schemas, setSchemas] = useState([]); const location = useLocation(); const navigate = useNavigate(); const { t, language, setLanguage } = useLanguage(); const { logout, user } = useAuth(); - useEffect(() => { - fetchSchemas(); - }, []); + const [recentExplore, setRecentExplore] = useState(() => { + try { + const saved = localStorage.getItem('recentExplore'); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); - const fetchSchemas = async () => { + const [recentEdit, setRecentEdit] = useState(() => { try { - const response = await api.get('/api/v1/schemas/'); - setSchemas(response.data); - } catch (error) { - console.error('Failed to fetch schemas', error); + const saved = localStorage.getItem('recentEdit'); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + useEffect(() => { + const path = location.pathname; + if (path.startsWith('/explorer/')) { + const parts = path.split('/'); + const schema = parts[2]; // /explorer/schema_name + if (schema) addToRecent(schema, 'explore'); + } else if (path.startsWith('/schemas/')) { + const parts = path.split('/'); + const schema = parts[2]; // /schemas/schema_name/edit + // specific check to avoid adding 'new' or if it's just /schemas (list) + if (schema && schema !== 'new' && parts.length > 2) { + addToRecent(schema, 'edit'); + } } + }, [location]); + + const addToRecent = (schema: string, type: 'explore' | 'edit') => { + const setFunc = type === 'explore' ? setRecentExplore : setRecentEdit; + const storageKey = type === 'explore' ? 'recentExplore' : 'recentEdit'; + + setFunc(prev => { + // Remove if exists, add to front, take top 3 + const newRecent = [schema, ...prev.filter(s => s !== schema)].slice(0, 3); + localStorage.setItem(storageKey, JSON.stringify(newRecent)); + return newRecent; + }); }; const handleLogout = () => { @@ -31,6 +62,11 @@ export default function DashboardLayout() { navigate('/login'); }; + // Dummy refresh function to satisfy potential outlet context consumers + const refreshSchemas = async () => { + // No longer needed for sidebar, but kept for interface compatibility + }; + const isAdmin = user?.role === 'system_admin' || user?.role === 'user_admin'; const isSystemAdmin = user?.role === 'system_admin'; @@ -44,7 +80,7 @@ export default function DashboardLayout() { {t('app.title')} -