@weirdfingers/baseboards 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +887 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/templates/README.md +120 -0
- package/templates/api/.env.example +62 -0
- package/templates/api/Dockerfile +32 -0
- package/templates/api/README.md +132 -0
- package/templates/api/alembic/env.py +106 -0
- package/templates/api/alembic/script.py.mako +28 -0
- package/templates/api/alembic/versions/20250101_000000_initial_schema.py +448 -0
- package/templates/api/alembic/versions/20251022_174729_remove_provider_name_from_generations.py +71 -0
- package/templates/api/alembic/versions/20251023_165852_switch_to_declarative_base_and_mapping.py +411 -0
- package/templates/api/alembic/versions/2025925_62735_add_seed_data_for_default_tenant.py +85 -0
- package/templates/api/alembic.ini +36 -0
- package/templates/api/config/generators.yaml +25 -0
- package/templates/api/config/storage_config.yaml +26 -0
- package/templates/api/docs/ADDING_GENERATORS.md +409 -0
- package/templates/api/docs/GENERATORS_API.md +502 -0
- package/templates/api/docs/MIGRATIONS.md +472 -0
- package/templates/api/docs/storage_providers.md +337 -0
- package/templates/api/pyproject.toml +165 -0
- package/templates/api/src/boards/__init__.py +10 -0
- package/templates/api/src/boards/api/app.py +171 -0
- package/templates/api/src/boards/api/auth.py +75 -0
- package/templates/api/src/boards/api/endpoints/__init__.py +3 -0
- package/templates/api/src/boards/api/endpoints/jobs.py +76 -0
- package/templates/api/src/boards/api/endpoints/setup.py +505 -0
- package/templates/api/src/boards/api/endpoints/sse.py +129 -0
- package/templates/api/src/boards/api/endpoints/storage.py +74 -0
- package/templates/api/src/boards/api/endpoints/tenant_registration.py +296 -0
- package/templates/api/src/boards/api/endpoints/webhooks.py +13 -0
- package/templates/api/src/boards/auth/__init__.py +15 -0
- package/templates/api/src/boards/auth/adapters/__init__.py +20 -0
- package/templates/api/src/boards/auth/adapters/auth0.py +220 -0
- package/templates/api/src/boards/auth/adapters/base.py +73 -0
- package/templates/api/src/boards/auth/adapters/clerk.py +172 -0
- package/templates/api/src/boards/auth/adapters/jwt.py +122 -0
- package/templates/api/src/boards/auth/adapters/none.py +102 -0
- package/templates/api/src/boards/auth/adapters/oidc.py +284 -0
- package/templates/api/src/boards/auth/adapters/supabase.py +110 -0
- package/templates/api/src/boards/auth/context.py +35 -0
- package/templates/api/src/boards/auth/factory.py +115 -0
- package/templates/api/src/boards/auth/middleware.py +221 -0
- package/templates/api/src/boards/auth/provisioning.py +129 -0
- package/templates/api/src/boards/auth/tenant_extraction.py +278 -0
- package/templates/api/src/boards/cli.py +354 -0
- package/templates/api/src/boards/config.py +116 -0
- package/templates/api/src/boards/database/__init__.py +7 -0
- package/templates/api/src/boards/database/cli.py +110 -0
- package/templates/api/src/boards/database/connection.py +252 -0
- package/templates/api/src/boards/database/models.py +19 -0
- package/templates/api/src/boards/database/seed_data.py +182 -0
- package/templates/api/src/boards/dbmodels/__init__.py +455 -0
- package/templates/api/src/boards/generators/__init__.py +57 -0
- package/templates/api/src/boards/generators/artifacts.py +53 -0
- package/templates/api/src/boards/generators/base.py +140 -0
- package/templates/api/src/boards/generators/implementations/__init__.py +12 -0
- package/templates/api/src/boards/generators/implementations/audio/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/audio/whisper.py +66 -0
- package/templates/api/src/boards/generators/implementations/image/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/image/dalle3.py +93 -0
- package/templates/api/src/boards/generators/implementations/image/flux_pro.py +85 -0
- package/templates/api/src/boards/generators/implementations/video/__init__.py +3 -0
- package/templates/api/src/boards/generators/implementations/video/lipsync.py +70 -0
- package/templates/api/src/boards/generators/loader.py +253 -0
- package/templates/api/src/boards/generators/registry.py +114 -0
- package/templates/api/src/boards/generators/resolution.py +515 -0
- package/templates/api/src/boards/generators/testmods/class_gen.py +34 -0
- package/templates/api/src/boards/generators/testmods/import_side_effect.py +35 -0
- package/templates/api/src/boards/graphql/__init__.py +7 -0
- package/templates/api/src/boards/graphql/access_control.py +136 -0
- package/templates/api/src/boards/graphql/mutations/root.py +136 -0
- package/templates/api/src/boards/graphql/queries/root.py +116 -0
- package/templates/api/src/boards/graphql/resolvers/__init__.py +8 -0
- package/templates/api/src/boards/graphql/resolvers/auth.py +12 -0
- package/templates/api/src/boards/graphql/resolvers/board.py +1055 -0
- package/templates/api/src/boards/graphql/resolvers/generation.py +889 -0
- package/templates/api/src/boards/graphql/resolvers/generator.py +50 -0
- package/templates/api/src/boards/graphql/resolvers/user.py +25 -0
- package/templates/api/src/boards/graphql/schema.py +81 -0
- package/templates/api/src/boards/graphql/types/board.py +102 -0
- package/templates/api/src/boards/graphql/types/generation.py +130 -0
- package/templates/api/src/boards/graphql/types/generator.py +17 -0
- package/templates/api/src/boards/graphql/types/user.py +47 -0
- package/templates/api/src/boards/jobs/repository.py +104 -0
- package/templates/api/src/boards/logging.py +195 -0
- package/templates/api/src/boards/middleware.py +339 -0
- package/templates/api/src/boards/progress/__init__.py +4 -0
- package/templates/api/src/boards/progress/models.py +25 -0
- package/templates/api/src/boards/progress/publisher.py +64 -0
- package/templates/api/src/boards/py.typed +0 -0
- package/templates/api/src/boards/redis_pool.py +118 -0
- package/templates/api/src/boards/storage/__init__.py +52 -0
- package/templates/api/src/boards/storage/base.py +363 -0
- package/templates/api/src/boards/storage/config.py +187 -0
- package/templates/api/src/boards/storage/factory.py +278 -0
- package/templates/api/src/boards/storage/implementations/__init__.py +27 -0
- package/templates/api/src/boards/storage/implementations/gcs.py +340 -0
- package/templates/api/src/boards/storage/implementations/local.py +201 -0
- package/templates/api/src/boards/storage/implementations/s3.py +294 -0
- package/templates/api/src/boards/storage/implementations/supabase.py +218 -0
- package/templates/api/src/boards/tenant_isolation.py +446 -0
- package/templates/api/src/boards/validation.py +262 -0
- package/templates/api/src/boards/workers/__init__.py +1 -0
- package/templates/api/src/boards/workers/actors.py +201 -0
- package/templates/api/src/boards/workers/cli.py +125 -0
- package/templates/api/src/boards/workers/context.py +188 -0
- package/templates/api/src/boards/workers/middleware.py +58 -0
- package/templates/api/src/py.typed +0 -0
- package/templates/compose.dev.yaml +39 -0
- package/templates/compose.yaml +109 -0
- package/templates/docker/env.example +23 -0
- package/templates/web/.env.example +28 -0
- package/templates/web/Dockerfile +51 -0
- package/templates/web/components.json +22 -0
- package/templates/web/imageLoader.js +18 -0
- package/templates/web/next-env.d.ts +5 -0
- package/templates/web/next.config.js +36 -0
- package/templates/web/package.json +37 -0
- package/templates/web/postcss.config.mjs +7 -0
- package/templates/web/public/favicon.ico +0 -0
- package/templates/web/src/app/boards/[boardId]/page.tsx +232 -0
- package/templates/web/src/app/globals.css +120 -0
- package/templates/web/src/app/layout.tsx +21 -0
- package/templates/web/src/app/page.tsx +35 -0
- package/templates/web/src/app/providers.tsx +18 -0
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +142 -0
- package/templates/web/src/components/boards/ArtifactPreview.tsx +125 -0
- package/templates/web/src/components/boards/GenerationGrid.tsx +45 -0
- package/templates/web/src/components/boards/GenerationInput.tsx +251 -0
- package/templates/web/src/components/boards/GeneratorSelector.tsx +89 -0
- package/templates/web/src/components/header.tsx +30 -0
- package/templates/web/src/components/ui/button.tsx +58 -0
- package/templates/web/src/components/ui/card.tsx +92 -0
- package/templates/web/src/components/ui/navigation-menu.tsx +168 -0
- package/templates/web/src/lib/utils.ts +6 -0
- package/templates/web/tsconfig.json +47 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server-Sent Events (SSE) endpoints for real-time generation progress.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
+
from fastapi.responses import StreamingResponse
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
from ...config import Settings
|
|
14
|
+
from ...database.connection import get_db_session
|
|
15
|
+
from ...jobs import repository as jobs_repo
|
|
16
|
+
from ...logging import get_logger
|
|
17
|
+
from ...redis_pool import get_redis_client
|
|
18
|
+
from ..auth import AuthenticatedUser, get_current_user
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
_settings = Settings()
|
|
25
|
+
# Use the shared Redis connection pool
|
|
26
|
+
_redis = get_redis_client()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/generations/{generation_id}/progress")
|
|
30
|
+
async def generation_progress_stream(
|
|
31
|
+
generation_id: str,
|
|
32
|
+
request: Request,
|
|
33
|
+
db: AsyncSession = Depends(get_db_session),
|
|
34
|
+
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
35
|
+
):
|
|
36
|
+
"""Server-sent events for job progress, backed by Redis pub/sub.
|
|
37
|
+
|
|
38
|
+
Requires authentication. Users can only monitor progress for their own generations
|
|
39
|
+
or generations within their tenant (depending on access control policy).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
logger.info(
|
|
43
|
+
"SSE: generation progress stream requested",
|
|
44
|
+
generation_id=generation_id,
|
|
45
|
+
url=request.url,
|
|
46
|
+
)
|
|
47
|
+
# Verify user has access to this generation
|
|
48
|
+
try:
|
|
49
|
+
generation = await jobs_repo.get_generation(db, generation_id)
|
|
50
|
+
|
|
51
|
+
# Check if user owns this generation or belongs to the same tenant
|
|
52
|
+
if generation.user_id != current_user.user_id:
|
|
53
|
+
# Allow tenant-level access (users in same tenant can see each other's jobs)
|
|
54
|
+
# You may want to make this more restrictive based on your requirements
|
|
55
|
+
if generation.tenant_id != current_user.tenant_id:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"User attempted to access generation belonging to different user",
|
|
58
|
+
user_id=str(current_user.user_id),
|
|
59
|
+
generation_id=generation_id,
|
|
60
|
+
owner_user_id=str(generation.user_id),
|
|
61
|
+
)
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=403,
|
|
64
|
+
detail="You don't have permission to access this generation",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger.info(
|
|
68
|
+
"User connected to progress stream",
|
|
69
|
+
user_id=str(current_user.user_id),
|
|
70
|
+
generation_id=generation_id,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
except HTTPException:
|
|
74
|
+
raise
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(
|
|
77
|
+
"Failed to verify access to generation",
|
|
78
|
+
generation_id=generation_id,
|
|
79
|
+
error=str(e),
|
|
80
|
+
)
|
|
81
|
+
raise HTTPException(status_code=404, detail="Generation not found") from e
|
|
82
|
+
|
|
83
|
+
channel = f"job:{generation_id}:progress"
|
|
84
|
+
|
|
85
|
+
async def event_stream():
|
|
86
|
+
pubsub = _redis.pubsub()
|
|
87
|
+
await pubsub.subscribe(channel)
|
|
88
|
+
logger.info(
|
|
89
|
+
"SSE: Subscribed to Redis channel",
|
|
90
|
+
generation_id=generation_id,
|
|
91
|
+
channel=channel,
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
if await request.is_disconnected():
|
|
96
|
+
logger.info(
|
|
97
|
+
"Client disconnected from progress stream",
|
|
98
|
+
generation_id=generation_id,
|
|
99
|
+
)
|
|
100
|
+
break
|
|
101
|
+
msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
|
102
|
+
if msg:
|
|
103
|
+
logger.info(
|
|
104
|
+
"SSE: pubsub message received",
|
|
105
|
+
message=msg,
|
|
106
|
+
msg_type=msg.get("type"),
|
|
107
|
+
)
|
|
108
|
+
if msg and msg.get("type") == "message":
|
|
109
|
+
data = msg["data"]
|
|
110
|
+
logger.info(
|
|
111
|
+
"SSE: sending progress data to client",
|
|
112
|
+
generation_id=generation_id,
|
|
113
|
+
data_preview=(data[:100] if isinstance(data, str) else str(data)[:100]),
|
|
114
|
+
)
|
|
115
|
+
yield f"data: {data}\n\n"
|
|
116
|
+
else:
|
|
117
|
+
# Send keep-alive every 15 seconds to prevent timeout
|
|
118
|
+
await asyncio.sleep(15)
|
|
119
|
+
logger.debug("SSE: sending keep-alive", generation_id=generation_id)
|
|
120
|
+
yield ": keep-alive\n\n"
|
|
121
|
+
finally:
|
|
122
|
+
logger.info(
|
|
123
|
+
"SSE: Cleaning up stream",
|
|
124
|
+
generation_id=generation_id,
|
|
125
|
+
)
|
|
126
|
+
await pubsub.unsubscribe(channel)
|
|
127
|
+
await pubsub.close()
|
|
128
|
+
|
|
129
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage endpoints for file uploads and management
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from fastapi.responses import FileResponse
|
|
9
|
+
|
|
10
|
+
from ...logging import get_logger
|
|
11
|
+
from ...storage.factory import create_storage_manager
|
|
12
|
+
from ...storage.implementations.local import LocalStorageProvider
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/status")
|
|
19
|
+
async def storage_status():
|
|
20
|
+
"""Storage status endpoint."""
|
|
21
|
+
return {"status": "Storage endpoint ready"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/{full_path:path}")
|
|
25
|
+
async def serve_file(full_path: str):
|
|
26
|
+
"""Serve a file from local storage.
|
|
27
|
+
|
|
28
|
+
This endpoint serves files that were uploaded to local storage.
|
|
29
|
+
The full_path includes the tenant_id/artifact_type/board_id/artifact_id/variant structure.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
logger.info("Serving file", full_path=full_path)
|
|
33
|
+
# Create storage manager to get the configured local storage path
|
|
34
|
+
storage_manager = create_storage_manager()
|
|
35
|
+
|
|
36
|
+
# Get the local provider (assumes 'local' is the provider name)
|
|
37
|
+
local_provider = storage_manager.providers.get("local")
|
|
38
|
+
if not local_provider:
|
|
39
|
+
raise HTTPException(status_code=500, detail="Local storage provider not configured")
|
|
40
|
+
|
|
41
|
+
# Type check: ensure it's a LocalStorageProvider
|
|
42
|
+
# This endpoint only serves local files; cloud providers return direct URLs
|
|
43
|
+
if not isinstance(local_provider, LocalStorageProvider):
|
|
44
|
+
raise HTTPException(
|
|
45
|
+
status_code=500,
|
|
46
|
+
detail="Storage provider does not support local file serving",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
base_path = local_provider.base_path
|
|
50
|
+
file_path = Path(base_path) / full_path
|
|
51
|
+
|
|
52
|
+
# Security check: ensure the resolved path is within base_path
|
|
53
|
+
try:
|
|
54
|
+
file_path.resolve().relative_to(Path(base_path).resolve())
|
|
55
|
+
except ValueError as e:
|
|
56
|
+
logger.warning("Path traversal attempt detected", requested_path=full_path)
|
|
57
|
+
raise HTTPException(status_code=403, detail="Access denied") from e
|
|
58
|
+
|
|
59
|
+
# Check if file exists
|
|
60
|
+
if not file_path.exists():
|
|
61
|
+
logger.warning("File not found", path=str(file_path))
|
|
62
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
63
|
+
|
|
64
|
+
if not file_path.is_file():
|
|
65
|
+
raise HTTPException(status_code=400, detail="Path is not a file")
|
|
66
|
+
|
|
67
|
+
# Serve the file
|
|
68
|
+
return FileResponse(file_path)
|
|
69
|
+
|
|
70
|
+
except HTTPException:
|
|
71
|
+
raise
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error("Error serving file", path=full_path, error=str(e))
|
|
74
|
+
raise HTTPException(status_code=500, detail="Internal server error") from e
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Self-service tenant registration endpoints.
|
|
3
|
+
|
|
4
|
+
This module provides endpoints for organizations to register new tenants
|
|
5
|
+
in multi-tenant mode, with optional approval workflows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
13
|
+
from pydantic import BaseModel, Field, field_validator
|
|
14
|
+
|
|
15
|
+
from ...auth.context import AuthContext
|
|
16
|
+
from ...auth.middleware import get_auth_context
|
|
17
|
+
from ...config import settings
|
|
18
|
+
from ...database.connection import get_async_session
|
|
19
|
+
from ...database.seed_data import ensure_tenant, seed_tenant_with_data
|
|
20
|
+
from ...logging import get_logger
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TenantRegistrationRequest(BaseModel):
|
|
28
|
+
"""Request model for self-service tenant registration."""
|
|
29
|
+
|
|
30
|
+
organization_name: str = Field(
|
|
31
|
+
..., min_length=1, max_length=255, description="Organization name for the new tenant"
|
|
32
|
+
)
|
|
33
|
+
organization_slug: str | None = Field(
|
|
34
|
+
None,
|
|
35
|
+
min_length=1,
|
|
36
|
+
max_length=255,
|
|
37
|
+
pattern=r"^[a-z0-9-]+$",
|
|
38
|
+
description="Desired tenant slug (auto-generated if not provided)",
|
|
39
|
+
)
|
|
40
|
+
admin_email: str = Field(..., description="Email of the organization administrator")
|
|
41
|
+
admin_name: str | None = Field(None, description="Full name of the organization administrator")
|
|
42
|
+
use_case: str | None = Field(
|
|
43
|
+
None, max_length=500, description="Brief description of intended use case"
|
|
44
|
+
)
|
|
45
|
+
organization_size: str | None = Field(
|
|
46
|
+
None, description="Size of organization (small, medium, large, enterprise)"
|
|
47
|
+
)
|
|
48
|
+
metadata: dict[str, Any] = Field(
|
|
49
|
+
default_factory=dict, description="Additional registration metadata"
|
|
50
|
+
)
|
|
51
|
+
include_sample_data: bool = Field(
|
|
52
|
+
default=True, description="Whether to include sample boards and data"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@field_validator("admin_email")
|
|
56
|
+
@classmethod
|
|
57
|
+
def validate_email(cls, v: str) -> str:
|
|
58
|
+
"""Basic email validation."""
|
|
59
|
+
import re
|
|
60
|
+
|
|
61
|
+
if not re.match(r"^[^@]+@[^@]+\.[^@]+$", v):
|
|
62
|
+
raise ValueError("Invalid email format")
|
|
63
|
+
return v.lower()
|
|
64
|
+
|
|
65
|
+
@field_validator("organization_size")
|
|
66
|
+
@classmethod
|
|
67
|
+
def validate_organization_size(cls, v: str | None) -> str | None:
|
|
68
|
+
"""Validate organization size values."""
|
|
69
|
+
if v is not None:
|
|
70
|
+
valid_sizes = {"small", "medium", "large", "enterprise"}
|
|
71
|
+
if v.lower() not in valid_sizes:
|
|
72
|
+
raise ValueError(f'Organization size must be one of: {", ".join(valid_sizes)}')
|
|
73
|
+
return v.lower() if v else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TenantRegistrationResponse(BaseModel):
|
|
77
|
+
"""Response model for tenant registration."""
|
|
78
|
+
|
|
79
|
+
tenant_id: str = Field(..., description="UUID of the registered tenant")
|
|
80
|
+
organization_name: str = Field(..., description="Organization name")
|
|
81
|
+
tenant_slug: str = Field(..., description="Assigned tenant slug")
|
|
82
|
+
status: str = Field(..., description="Registration status")
|
|
83
|
+
admin_instructions: str = Field(..., description="Next steps for the administrator")
|
|
84
|
+
dashboard_url: str | None = Field(None, description="URL to access tenant dashboard")
|
|
85
|
+
api_access: dict[str, Any] = Field(..., description="API access information")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TenantRegistrationStatus(BaseModel):
|
|
89
|
+
"""Model for checking registration status."""
|
|
90
|
+
|
|
91
|
+
enabled: bool = Field(..., description="Whether self-service registration is enabled")
|
|
92
|
+
requires_approval: bool = Field(..., description="Whether registrations require approval")
|
|
93
|
+
max_tenants_per_user: int | None = Field(None, description="Maximum tenants per user")
|
|
94
|
+
allowed_domains: list[str] | None = Field(None, description="Allowed email domains")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/registration/status", response_model=TenantRegistrationStatus)
|
|
98
|
+
async def get_registration_status() -> TenantRegistrationStatus:
|
|
99
|
+
"""
|
|
100
|
+
Get the current tenant registration status and configuration.
|
|
101
|
+
"""
|
|
102
|
+
return TenantRegistrationStatus(
|
|
103
|
+
enabled=settings.multi_tenant_mode,
|
|
104
|
+
requires_approval=getattr(settings, "tenant_registration_requires_approval", False),
|
|
105
|
+
max_tenants_per_user=getattr(settings, "max_tenants_per_user", None),
|
|
106
|
+
allowed_domains=getattr(settings, "tenant_registration_allowed_domains", None),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/register", response_model=TenantRegistrationResponse)
|
|
111
|
+
async def register_tenant(
|
|
112
|
+
request: TenantRegistrationRequest,
|
|
113
|
+
auth_context: AuthContext = Depends(get_auth_context),
|
|
114
|
+
) -> TenantRegistrationResponse:
|
|
115
|
+
"""
|
|
116
|
+
Register a new tenant for an organization.
|
|
117
|
+
|
|
118
|
+
This endpoint allows authenticated users to create new tenants for their
|
|
119
|
+
organizations. In multi-tenant mode, this enables self-service onboarding.
|
|
120
|
+
|
|
121
|
+
Requirements:
|
|
122
|
+
- Multi-tenant mode must be enabled
|
|
123
|
+
- User must be authenticated
|
|
124
|
+
- Organization slug must be unique
|
|
125
|
+
- Optional: email domain validation
|
|
126
|
+
- Optional: approval workflow
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Validate prerequisites
|
|
130
|
+
if not settings.multi_tenant_mode:
|
|
131
|
+
raise HTTPException(
|
|
132
|
+
status_code=400, detail="Tenant registration is only available in multi-tenant mode"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not auth_context.user_id:
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
status_code=401, detail="Authentication required for tenant registration"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Validate email domain if restrictions are configured
|
|
141
|
+
allowed_domains = getattr(settings, "tenant_registration_allowed_domains", None)
|
|
142
|
+
if allowed_domains:
|
|
143
|
+
admin_domain = request.admin_email.split("@")[1].lower()
|
|
144
|
+
if admin_domain not in allowed_domains:
|
|
145
|
+
raise HTTPException(
|
|
146
|
+
status_code=400,
|
|
147
|
+
detail=f"Email domain '{admin_domain}' is not allowed for registration",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Check if user has reached tenant limit
|
|
151
|
+
max_tenants = getattr(settings, "max_tenants_per_user", None)
|
|
152
|
+
if max_tenants:
|
|
153
|
+
# TODO: Implement tenant count check per user
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Tenant limit check not implemented",
|
|
156
|
+
user_id=str(auth_context.user_id),
|
|
157
|
+
max_tenants=max_tenants,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Generate tenant slug if not provided
|
|
161
|
+
tenant_slug = request.organization_slug
|
|
162
|
+
if not tenant_slug:
|
|
163
|
+
tenant_slug = _generate_slug_from_name(request.organization_name)
|
|
164
|
+
|
|
165
|
+
logger.info(
|
|
166
|
+
"Processing tenant registration request",
|
|
167
|
+
organization_name=request.organization_name,
|
|
168
|
+
tenant_slug=tenant_slug,
|
|
169
|
+
admin_email=request.admin_email,
|
|
170
|
+
user_id=str(auth_context.user_id),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
async with get_async_session() as db:
|
|
175
|
+
# Create tenant with sample data
|
|
176
|
+
if request.include_sample_data:
|
|
177
|
+
tenant_id = await seed_tenant_with_data(
|
|
178
|
+
db,
|
|
179
|
+
tenant_name=request.organization_name,
|
|
180
|
+
tenant_slug=tenant_slug,
|
|
181
|
+
tenant_settings={
|
|
182
|
+
"admin_email": request.admin_email,
|
|
183
|
+
"admin_name": request.admin_name,
|
|
184
|
+
"use_case": request.use_case,
|
|
185
|
+
"organization_size": request.organization_size,
|
|
186
|
+
"registered_by": str(auth_context.user_id),
|
|
187
|
+
"registration_metadata": request.metadata,
|
|
188
|
+
},
|
|
189
|
+
include_sample_data=True,
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
tenant_id = await ensure_tenant(
|
|
193
|
+
db,
|
|
194
|
+
name=request.organization_name,
|
|
195
|
+
slug=tenant_slug,
|
|
196
|
+
settings_dict={
|
|
197
|
+
"admin_email": request.admin_email,
|
|
198
|
+
"admin_name": request.admin_name,
|
|
199
|
+
"use_case": request.use_case,
|
|
200
|
+
"organization_size": request.organization_size,
|
|
201
|
+
"registered_by": str(auth_context.user_id),
|
|
202
|
+
"registration_metadata": request.metadata,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Determine status based on approval requirements
|
|
207
|
+
requires_approval = getattr(settings, "tenant_registration_requires_approval", False)
|
|
208
|
+
status = "pending_approval" if requires_approval else "active"
|
|
209
|
+
|
|
210
|
+
# Generate dashboard URL if available
|
|
211
|
+
dashboard_url = _generate_dashboard_url(tenant_slug)
|
|
212
|
+
|
|
213
|
+
logger.info(
|
|
214
|
+
"Tenant registration completed",
|
|
215
|
+
tenant_id=str(tenant_id),
|
|
216
|
+
tenant_slug=tenant_slug,
|
|
217
|
+
status=status,
|
|
218
|
+
admin_email=request.admin_email,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return TenantRegistrationResponse(
|
|
222
|
+
tenant_id=str(tenant_id),
|
|
223
|
+
organization_name=request.organization_name,
|
|
224
|
+
tenant_slug=tenant_slug,
|
|
225
|
+
status=status,
|
|
226
|
+
admin_instructions=_generate_admin_instructions(status, tenant_slug),
|
|
227
|
+
dashboard_url=dashboard_url,
|
|
228
|
+
api_access={
|
|
229
|
+
"tenant_header": f"X-Tenant: {tenant_slug}",
|
|
230
|
+
"graphql_endpoint": "/graphql",
|
|
231
|
+
"api_base_url": "/api",
|
|
232
|
+
"authentication_required": True,
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(
|
|
238
|
+
"Tenant registration failed",
|
|
239
|
+
error=str(e),
|
|
240
|
+
organization_name=request.organization_name,
|
|
241
|
+
tenant_slug=tenant_slug,
|
|
242
|
+
)
|
|
243
|
+
raise HTTPException(status_code=500, detail=f"Failed to register tenant: {str(e)}") from e
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _generate_slug_from_name(organization_name: str) -> str:
|
|
247
|
+
"""Generate a URL-safe slug from organization name."""
|
|
248
|
+
import re
|
|
249
|
+
import uuid
|
|
250
|
+
|
|
251
|
+
# Basic normalization
|
|
252
|
+
slug = organization_name.lower().strip()
|
|
253
|
+
|
|
254
|
+
# Replace spaces and special characters with hyphens
|
|
255
|
+
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
|
256
|
+
|
|
257
|
+
# Remove multiple consecutive hyphens
|
|
258
|
+
slug = re.sub(r"-+", "-", slug)
|
|
259
|
+
|
|
260
|
+
# Remove leading/trailing hyphens
|
|
261
|
+
slug = slug.strip("-")
|
|
262
|
+
|
|
263
|
+
# Ensure it's not empty
|
|
264
|
+
if not slug or len(slug) < 3:
|
|
265
|
+
slug = f"org-{uuid.uuid4().hex[:8]}"
|
|
266
|
+
|
|
267
|
+
# Ensure it's not too long
|
|
268
|
+
if len(slug) > 50:
|
|
269
|
+
slug = slug[:47] + f"-{uuid.uuid4().hex[:2]}"
|
|
270
|
+
|
|
271
|
+
return slug
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _generate_dashboard_url(tenant_slug: str) -> str | None:
|
|
275
|
+
"""Generate dashboard URL for the tenant."""
|
|
276
|
+
# This would typically point to your frontend application
|
|
277
|
+
frontend_base_url = getattr(settings, "frontend_base_url", None)
|
|
278
|
+
if frontend_base_url:
|
|
279
|
+
return f"{frontend_base_url}/?tenant={tenant_slug}"
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _generate_admin_instructions(status: str, tenant_slug: str) -> str:
|
|
284
|
+
"""Generate setup instructions for the tenant administrator."""
|
|
285
|
+
if status == "pending_approval":
|
|
286
|
+
return (
|
|
287
|
+
"Your tenant registration is pending approval. "
|
|
288
|
+
"You will receive an email notification when your tenant is activated. "
|
|
289
|
+
f"Your tenant slug is '{tenant_slug}' - save this for future reference."
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
return (
|
|
293
|
+
f"Your tenant '{tenant_slug}' is ready to use! "
|
|
294
|
+
"Include the X-Tenant header in all API requests. "
|
|
295
|
+
"Visit the dashboard to get started with boards and content generation."
|
|
296
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Webhook endpoints for external service integrations
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/status")
|
|
11
|
+
async def webhook_status():
|
|
12
|
+
"""Webhook status endpoint."""
|
|
13
|
+
return {"status": "Webhook endpoint ready"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Authentication and authorization system for Boards."""
|
|
2
|
+
|
|
3
|
+
from .adapters.base import AuthAdapter, Principal
|
|
4
|
+
from .context import AuthContext
|
|
5
|
+
from .factory import get_auth_adapter
|
|
6
|
+
from .middleware import get_auth_context, get_auth_context_optional
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuthAdapter",
|
|
10
|
+
"Principal",
|
|
11
|
+
"AuthContext",
|
|
12
|
+
"get_auth_context",
|
|
13
|
+
"get_auth_context_optional",
|
|
14
|
+
"get_auth_adapter",
|
|
15
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Authentication adapters for different providers."""
|
|
2
|
+
|
|
3
|
+
from .auth0 import Auth0OIDCAdapter
|
|
4
|
+
from .base import AuthAdapter, Principal
|
|
5
|
+
from .clerk import ClerkAuthAdapter
|
|
6
|
+
from .jwt import JWTAuthAdapter
|
|
7
|
+
from .none import NoAuthAdapter
|
|
8
|
+
from .oidc import OIDCAdapter
|
|
9
|
+
from .supabase import SupabaseAuthAdapter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AuthAdapter",
|
|
13
|
+
"Principal",
|
|
14
|
+
"SupabaseAuthAdapter",
|
|
15
|
+
"JWTAuthAdapter",
|
|
16
|
+
"NoAuthAdapter",
|
|
17
|
+
"ClerkAuthAdapter",
|
|
18
|
+
"Auth0OIDCAdapter",
|
|
19
|
+
"OIDCAdapter",
|
|
20
|
+
]
|