Compare commits
1 Commits
v0.20.0
...
01ade3fb3c
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ade3fb3c |
36
.env.example
36
.env.example
@@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here
|
|||||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||||
# Redirect URI must match what's configured in Authentik
|
# Redirect URI must match what's configured in Authentik
|
||||||
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
|
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# Authentik PostgreSQL Database
|
# Authentik PostgreSQL Database
|
||||||
@@ -215,9 +215,11 @@ NODE_ENV=development
|
|||||||
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
|
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
|
||||||
# For local builds, use docker-compose.build.yml instead
|
# For local builds, use docker-compose.build.yml instead
|
||||||
# Options:
|
# Options:
|
||||||
# - latest: Pull latest images from registry (default, built from main branch)
|
# - dev: Pull development images from registry (default, built from develop branch)
|
||||||
|
# - latest: Pull latest stable images from registry (built from main branch)
|
||||||
|
# - <commit-sha>: Use specific commit SHA tag (e.g., 658ec077)
|
||||||
# - <version>: Use specific version tag (e.g., v1.0.0)
|
# - <version>: Use specific version tag (e.g., v1.0.0)
|
||||||
IMAGE_TAG=latest
|
IMAGE_TAG=dev
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Docker Compose Profiles
|
# Docker Compose Profiles
|
||||||
@@ -314,19 +316,17 @@ COORDINATOR_ENABLED=true
|
|||||||
# TTL is in seconds, limits are per TTL window
|
# TTL is in seconds, limits are per TTL window
|
||||||
|
|
||||||
# Global rate limit (applies to all endpoints unless overridden)
|
# Global rate limit (applies to all endpoints unless overridden)
|
||||||
# Time window in seconds
|
RATE_LIMIT_TTL=60 # Time window in seconds
|
||||||
RATE_LIMIT_TTL=60
|
RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
|
||||||
# Requests per window
|
|
||||||
RATE_LIMIT_GLOBAL_LIMIT=100
|
|
||||||
|
|
||||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
|
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
|
||||||
RATE_LIMIT_WEBHOOK_LIMIT=60
|
RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
|
||||||
|
|
||||||
# Coordinator endpoints (/coordinator/*) — requests per minute
|
# Coordinator endpoints (/coordinator/*)
|
||||||
RATE_LIMIT_COORDINATOR_LIMIT=100
|
RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
|
||||||
|
|
||||||
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
|
# Health check endpoints (/coordinator/health)
|
||||||
RATE_LIMIT_HEALTH_LIMIT=300
|
RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
|
||||||
|
|
||||||
# Storage backend for rate limiting (redis or memory)
|
# Storage backend for rate limiting (redis or memory)
|
||||||
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
||||||
@@ -361,17 +361,17 @@ RATE_LIMIT_STORAGE=redis
|
|||||||
# a single workspace.
|
# a single workspace.
|
||||||
MATRIX_HOMESERVER_URL=http://synapse:8008
|
MATRIX_HOMESERVER_URL=http://synapse:8008
|
||||||
MATRIX_ACCESS_TOKEN=
|
MATRIX_ACCESS_TOKEN=
|
||||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
|
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
|
||||||
MATRIX_SERVER_NAME=matrix.woltje.com
|
MATRIX_SERVER_NAME=matrix.example.com
|
||||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
|
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
|
||||||
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Matrix / Synapse Deployment
|
# Matrix / Synapse Deployment
|
||||||
# ======================
|
# ======================
|
||||||
# Domains for Traefik routing to Matrix services
|
# Domains for Traefik routing to Matrix services
|
||||||
MATRIX_DOMAIN=matrix.woltje.com
|
MATRIX_DOMAIN=matrix.example.com
|
||||||
ELEMENT_DOMAIN=chat.woltje.com
|
ELEMENT_DOMAIN=chat.example.com
|
||||||
|
|
||||||
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
||||||
SYNAPSE_POSTGRES_DB=synapse
|
SYNAPSE_POSTGRES_DB=synapse
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"mission_id": "prd-implementation-20260222",
|
|
||||||
"name": "PRD implementation",
|
|
||||||
"description": "",
|
|
||||||
"project_path": "/home/jwoltje/src/mosaic-stack",
|
|
||||||
"created_at": "2026-02-23T03:20:55Z",
|
|
||||||
"status": "active",
|
|
||||||
"task_prefix": "",
|
|
||||||
"quality_gates": "",
|
|
||||||
"milestone_version": "0.0.20",
|
|
||||||
"milestones": [],
|
|
||||||
"sessions": []
|
|
||||||
}
|
|
||||||
@@ -85,11 +85,12 @@ install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
|
|||||||
|
|
||||||
## Image Tagging
|
## Image Tagging
|
||||||
|
|
||||||
| Condition | Tag | Purpose |
|
| Condition | Tag | Purpose |
|
||||||
| ------------- | -------------------------- | -------------------------- |
|
| ---------------- | -------------------------- | -------------------------- |
|
||||||
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
|
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
|
||||||
| `main` branch | `latest` | Current latest build |
|
| `main` branch | `latest` | Current production release |
|
||||||
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
|
| `develop` branch | `dev` | Current development build |
|
||||||
|
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
|
||||||
|
|
||||||
## Required Secrets
|
## Required Secrets
|
||||||
|
|
||||||
@@ -137,5 +138,5 @@ Fails on blockers or critical/high severity security findings.
|
|||||||
|
|
||||||
### Pipeline runs Docker builds on pull requests
|
### Pipeline runs Docker builds on pull requests
|
||||||
|
|
||||||
- Docker build steps have `when: branch: [main]` guards
|
- Docker build steps have `when: branch: [main, develop]` guards
|
||||||
- PRs only run quality gates, not Docker builds
|
- PRs only run quality gates, not Docker builds
|
||||||
|
|||||||
@@ -24,13 +24,6 @@ variables:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
- &turbo_env
|
|
||||||
TURBO_API:
|
|
||||||
from_secret: turbo_api
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
TURBO_TEAM:
|
|
||||||
from_secret: turbo_team
|
|
||||||
- &kaniko_setup |
|
- &kaniko_setup |
|
||||||
mkdir -p /kaniko/.docker
|
mkdir -p /kaniko/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||||
@@ -59,6 +52,17 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
|
lint:
|
||||||
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
commands:
|
||||||
|
- *use_deps
|
||||||
|
- pnpm --filter "@mosaic/api" lint
|
||||||
|
depends_on:
|
||||||
|
- prisma-generate
|
||||||
|
- build-shared
|
||||||
|
|
||||||
prisma-generate:
|
prisma-generate:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
@@ -69,27 +73,26 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
lint:
|
build-shared:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo lint --filter=@mosaic/api
|
- pnpm --filter "@mosaic/shared" build
|
||||||
depends_on:
|
depends_on:
|
||||||
- prisma-generate
|
- install
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo typecheck --filter=@mosaic/api
|
- pnpm --filter "@mosaic/api" typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- prisma-generate
|
- prisma-generate
|
||||||
|
- build-shared
|
||||||
|
|
||||||
prisma-migrate:
|
prisma-migrate:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -121,7 +124,6 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo build --filter=@mosaic/api
|
- pnpm turbo build --filter=@mosaic/api
|
||||||
@@ -150,10 +152,12 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:dev"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
@@ -176,7 +180,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="dev"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -184,7 +188,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-api
|
- docker-build-api
|
||||||
@@ -226,7 +230,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-api"
|
link_package "stack-api"
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-api
|
- security-trivy-api
|
||||||
|
|||||||
@@ -92,10 +92,12 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:dev"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- ruff-check
|
- ruff-check
|
||||||
@@ -122,7 +124,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="dev"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -130,7 +132,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-coordinator
|
- docker-build-coordinator
|
||||||
@@ -172,7 +174,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-coordinator"
|
link_package "stack-coordinator"
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-coordinator
|
- security-trivy-coordinator
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:dev"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
docker-build-openbao:
|
docker-build-openbao:
|
||||||
@@ -59,10 +61,12 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:dev"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
# === Container Security Scans ===
|
# === Container Security Scans ===
|
||||||
@@ -83,7 +87,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="dev"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -91,7 +95,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-postgres
|
- docker-build-postgres
|
||||||
@@ -112,7 +116,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="dev"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -120,7 +124,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-openbao
|
- docker-build-openbao
|
||||||
@@ -163,7 +167,7 @@ steps:
|
|||||||
link_package "stack-postgres"
|
link_package "stack-postgres"
|
||||||
link_package "stack-openbao"
|
link_package "stack-openbao"
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-postgres
|
- security-trivy-postgres
|
||||||
|
|||||||
@@ -24,13 +24,6 @@ variables:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
- &turbo_env
|
|
||||||
TURBO_API:
|
|
||||||
from_secret: turbo_api
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
TURBO_TEAM:
|
|
||||||
from_secret: turbo_team
|
|
||||||
- &kaniko_setup |
|
- &kaniko_setup |
|
||||||
mkdir -p /kaniko/.docker
|
mkdir -p /kaniko/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||||
@@ -55,10 +48,9 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo lint --filter=@mosaic/orchestrator
|
- pnpm --filter "@mosaic/orchestrator" lint
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
@@ -66,10 +58,9 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo typecheck --filter=@mosaic/orchestrator
|
- pnpm --filter "@mosaic/orchestrator" typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
@@ -77,10 +68,9 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo test --filter=@mosaic/orchestrator
|
- pnpm --filter "@mosaic/orchestrator" test
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
@@ -91,7 +81,6 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo build --filter=@mosaic/orchestrator
|
- pnpm turbo build --filter=@mosaic/orchestrator
|
||||||
@@ -120,10 +109,12 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:dev"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
@@ -146,7 +137,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="dev"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -154,7 +145,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-orchestrator
|
- docker-build-orchestrator
|
||||||
@@ -196,7 +187,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-orchestrator"
|
link_package "stack-orchestrator"
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-orchestrator
|
- security-trivy-orchestrator
|
||||||
|
|||||||
@@ -24,13 +24,6 @@ variables:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
- &turbo_env
|
|
||||||
TURBO_API:
|
|
||||||
from_secret: turbo_api
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
TURBO_TEAM:
|
|
||||||
from_secret: turbo_team
|
|
||||||
- &kaniko_setup |
|
- &kaniko_setup |
|
||||||
mkdir -p /kaniko/.docker
|
mkdir -p /kaniko/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||||
@@ -51,38 +44,46 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
|
build-shared:
|
||||||
|
image: *node_image
|
||||||
|
environment:
|
||||||
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
commands:
|
||||||
|
- *use_deps
|
||||||
|
- pnpm --filter "@mosaic/shared" build
|
||||||
|
- pnpm --filter "@mosaic/ui" build
|
||||||
|
depends_on:
|
||||||
|
- install
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo lint --filter=@mosaic/web
|
- pnpm --filter "@mosaic/web" lint
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- build-shared
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo typecheck --filter=@mosaic/web
|
- pnpm --filter "@mosaic/web" typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- build-shared
|
||||||
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo test --filter=@mosaic/web
|
- pnpm --filter "@mosaic/web" test
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- build-shared
|
||||||
|
|
||||||
# === Build ===
|
# === Build ===
|
||||||
|
|
||||||
@@ -91,7 +92,6 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
<<: *turbo_env
|
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo build --filter=@mosaic/web
|
- pnpm turbo build --filter=@mosaic/web
|
||||||
@@ -120,10 +120,12 @@ steps:
|
|||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
||||||
|
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:dev"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
@@ -146,7 +148,7 @@ steps:
|
|||||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="latest"
|
||||||
else
|
else
|
||||||
SCAN_TAG="latest"
|
SCAN_TAG="dev"
|
||||||
fi
|
fi
|
||||||
mkdir -p ~/.docker
|
mkdir -p ~/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||||
@@ -154,7 +156,7 @@ steps:
|
|||||||
--ignorefile .trivyignore \
|
--ignorefile .trivyignore \
|
||||||
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
|
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-build-web
|
- docker-build-web
|
||||||
@@ -196,7 +198,7 @@ steps:
|
|||||||
}
|
}
|
||||||
link_package "stack-web"
|
link_package "stack-web"
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main, develop]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
depends_on:
|
depends_on:
|
||||||
- security-trivy-web
|
- security-trivy-web
|
||||||
|
|||||||
15
AGENTS.md
15
AGENTS.md
@@ -46,21 +46,6 @@ pnpm lint
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Versioning Protocol (HARD GATE)
|
|
||||||
|
|
||||||
**This project is ALPHA. All versions MUST be `0.0.x`.**
|
|
||||||
|
|
||||||
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
|
|
||||||
- Every milestone bump increments the patch: `0.0.20` → `0.0.21` → `0.0.22`, etc.
|
|
||||||
- ALL package.json files in the monorepo MUST stay in sync at the same version.
|
|
||||||
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
|
|
||||||
- The script rejects any version >= `0.1.0`.
|
|
||||||
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
|
|
||||||
|
|
||||||
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
|
|
||||||
|
|
||||||
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
|
|
||||||
|
|
||||||
## Standards and Quality
|
## Standards and Quality
|
||||||
|
|
||||||
- Enforce strict typing and no unsafe shortcuts.
|
- Enforce strict typing and no unsafe shortcuts.
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -232,7 +232,7 @@ docker compose -f docker-compose.openbao.yml up -d
|
|||||||
sleep 30 # Wait for auto-initialization
|
sleep 30 # Wait for auto-initialization
|
||||||
|
|
||||||
# 5. Deploy swarm stack
|
# 5. Deploy swarm stack
|
||||||
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
|
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
|
||||||
|
|
||||||
# 6. Check deployment status
|
# 6. Check deployment status
|
||||||
docker stack services mosaic
|
docker stack services mosaic
|
||||||
@@ -526,9 +526,10 @@ KNOWLEDGE_CACHE_TTL=300 # 5 minutes
|
|||||||
|
|
||||||
### Branch Strategy
|
### Branch Strategy
|
||||||
|
|
||||||
- `main` — Trunk branch (all development merges here)
|
- `main` — Stable releases only
|
||||||
- `feature/*` — Feature branches from main
|
- `develop` — Active development (default working branch)
|
||||||
- `fix/*` — Bug fix branches from main
|
- `feature/*` — Feature branches from develop
|
||||||
|
- `fix/*` — Bug fix branches
|
||||||
|
|
||||||
### Running Locally
|
### Running Locally
|
||||||
|
|
||||||
@@ -738,7 +739,7 @@ See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for
|
|||||||
4. Run tests: `pnpm test`
|
4. Run tests: `pnpm test`
|
||||||
5. Build: `pnpm build`
|
5. Build: `pnpm build`
|
||||||
6. Commit with conventional format: `feat(#issue): Description`
|
6. Commit with conventional format: `feat(#issue): Description`
|
||||||
7. Push and create a pull request to `main`
|
7. Push and create a pull request to `develop`
|
||||||
|
|
||||||
### Commit Format
|
### Commit Format
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ COPY turbo.json ./
|
|||||||
# ======================
|
# ======================
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
|
||||||
# and OpenSSL for Prisma engine detection
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
python3 make g++ openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy all package.json files for workspace resolution
|
# Copy all package.json files for workspace resolution
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
COPY packages/ui/package.json ./packages/ui/
|
||||||
@@ -31,11 +25,7 @@ COPY packages/config/package.json ./packages/config/
|
|||||||
COPY apps/api/package.json ./apps/api/
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
RUN pnpm install --frozen-lockfile
|
||||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
|
||||||
RUN pnpm install --frozen-lockfile \
|
|
||||||
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
|
|
||||||
&& npx node-gyp rebuild 2>&1 || true
|
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Builder stage
|
# Builder stage
|
||||||
@@ -68,11 +58,7 @@ FROM node:24-slim AS production
|
|||||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
||||||
|
|
||||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||||
# - openssl: Prisma engine detection requires libssl
|
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||||
# - No build tools needed here — native addons are compiled in the deps stage
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
|
||||||
&& chmod 755 /usr/local/bin/dumb-init \
|
&& chmod 755 /usr/local/bin/dumb-init \
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/api",
|
"name": "@mosaic/api",
|
||||||
"version": "0.0.20",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -66,7 +66,6 @@
|
|||||||
"marked-gfm-heading-id": "^4.1.3",
|
"marked-gfm-heading-id": "^4.1.3",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"matrix-bot-sdk": "^0.8.0",
|
"matrix-bot-sdk": "^0.8.0",
|
||||||
"node-pty": "^1.0.0",
|
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"openai": "^6.17.0",
|
"openai": "^6.17.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "terminal_sessions" (
|
|
||||||
"id" UUID NOT NULL,
|
|
||||||
"workspace_id" UUID NOT NULL,
|
|
||||||
"name" TEXT NOT NULL DEFAULT 'Terminal',
|
|
||||||
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
|
||||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"closed_at" TIMESTAMPTZ,
|
|
||||||
|
|
||||||
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- AlterTable: add tone and formality_level columns to personalities
|
|
||||||
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
|
|
||||||
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
|
||||||
previewFeatures = ["postgresqlExtensions"]
|
previewFeatures = ["postgresqlExtensions"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +206,6 @@ enum CredentialScope {
|
|||||||
SYSTEM
|
SYSTEM
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TerminalSessionStatus {
|
|
||||||
ACTIVE
|
|
||||||
CLOSED
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -303,7 +297,6 @@ model Workspace {
|
|||||||
federationEventSubscriptions FederationEventSubscription[]
|
federationEventSubscriptions FederationEventSubscription[]
|
||||||
llmUsageLogs LlmUsageLog[]
|
llmUsageLogs LlmUsageLog[]
|
||||||
userCredentials UserCredential[]
|
userCredentials UserCredential[]
|
||||||
terminalSessions TerminalSession[]
|
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("workspaces")
|
@@map("workspaces")
|
||||||
@@ -1068,10 +1061,6 @@ model Personality {
|
|||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
|
|
||||||
// Tone and formality
|
|
||||||
tone String @default("neutral")
|
|
||||||
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
|
|
||||||
|
|
||||||
// System prompt
|
// System prompt
|
||||||
systemPrompt String @map("system_prompt") @db.Text
|
systemPrompt String @map("system_prompt") @db.Text
|
||||||
|
|
||||||
@@ -1518,23 +1507,3 @@ model LlmUsageLog {
|
|||||||
@@index([conversationId])
|
@@index([conversationId])
|
||||||
@@map("llm_usage_logs")
|
@@map("llm_usage_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// TERMINAL MODULE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
model TerminalSession {
|
|
||||||
id String @id @default(uuid()) @db.Uuid
|
|
||||||
workspaceId String @map("workspace_id") @db.Uuid
|
|
||||||
name String @default("Terminal")
|
|
||||||
status TerminalSessionStatus @default(ACTIVE)
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
|
||||||
closedAt DateTime? @map("closed_at") @db.Timestamptz
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([workspaceId])
|
|
||||||
@@index([workspaceId, status])
|
|
||||||
@@map("terminal_sessions")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -65,136 +65,6 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// WIDGET DEFINITIONS (global, not workspace-scoped)
|
|
||||||
// ============================================
|
|
||||||
const widgetDefs = [
|
|
||||||
{
|
|
||||||
name: "TasksWidget",
|
|
||||||
displayName: "Tasks",
|
|
||||||
description: "View and manage your tasks",
|
|
||||||
component: "TasksWidget",
|
|
||||||
defaultWidth: 2,
|
|
||||||
defaultHeight: 2,
|
|
||||||
minWidth: 1,
|
|
||||||
minHeight: 2,
|
|
||||||
maxWidth: 4,
|
|
||||||
maxHeight: null,
|
|
||||||
configSchema: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "CalendarWidget",
|
|
||||||
displayName: "Calendar",
|
|
||||||
description: "View upcoming events and schedule",
|
|
||||||
component: "CalendarWidget",
|
|
||||||
defaultWidth: 2,
|
|
||||||
defaultHeight: 2,
|
|
||||||
minWidth: 2,
|
|
||||||
minHeight: 2,
|
|
||||||
maxWidth: 4,
|
|
||||||
maxHeight: null,
|
|
||||||
configSchema: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "QuickCaptureWidget",
|
|
||||||
displayName: "Quick Capture",
|
|
||||||
description: "Quickly capture notes and tasks",
|
|
||||||
component: "QuickCaptureWidget",
|
|
||||||
defaultWidth: 2,
|
|
||||||
defaultHeight: 1,
|
|
||||||
minWidth: 2,
|
|
||||||
minHeight: 1,
|
|
||||||
maxWidth: 4,
|
|
||||||
maxHeight: 2,
|
|
||||||
configSchema: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "AgentStatusWidget",
|
|
||||||
displayName: "Agent Status",
|
|
||||||
description: "Monitor agent activity and status",
|
|
||||||
component: "AgentStatusWidget",
|
|
||||||
defaultWidth: 2,
|
|
||||||
defaultHeight: 2,
|
|
||||||
minWidth: 1,
|
|
||||||
minHeight: 2,
|
|
||||||
maxWidth: 3,
|
|
||||||
maxHeight: null,
|
|
||||||
configSchema: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ActiveProjectsWidget",
|
|
||||||
displayName: "Active Projects & Agent Chains",
|
|
||||||
description: "View active projects and running agent sessions",
|
|
||||||
component: "ActiveProjectsWidget",
|
|
||||||
defaultWidth: 2,
|
|
||||||
defaultHeight: 3,
|
|
||||||
minWidth: 2,
|
|
||||||
minHeight: 2,
|
|
||||||
maxWidth: 4,
|
|
||||||
maxHeight: null,
|
|
||||||
configSchema: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "TaskProgressWidget",
|
|
||||||
displayName: "Task Progress",
|
|
||||||
description: "Live progress of orchestrator agent tasks",
|
|
||||||
component: "TaskProgressWidget",
|
|
||||||
defaultWidth: 2,
|
|
||||||
defaultHeight: 2,
|
|
||||||
minWidth: 1,
|
|
||||||
minHeight: 2,
|
|
||||||
maxWidth: 3,
|
|
||||||
maxHeight: null,
|
|
||||||
configSchema: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "OrchestratorEventsWidget",
|
|
||||||
displayName: "Orchestrator Events",
|
|
||||||
description: "Recent orchestration events with stream/Matrix visibility",
|
|
||||||
component: "OrchestratorEventsWidget",
|
|
||||||
defaultWidth: 2,
|
|
||||||
defaultHeight: 2,
|
|
||||||
minWidth: 1,
|
|
||||||
minHeight: 2,
|
|
||||||
maxWidth: 4,
|
|
||||||
maxHeight: null,
|
|
||||||
configSchema: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const wd of widgetDefs) {
|
|
||||||
await prisma.widgetDefinition.upsert({
|
|
||||||
where: { name: wd.name },
|
|
||||||
update: {
|
|
||||||
displayName: wd.displayName,
|
|
||||||
description: wd.description,
|
|
||||||
component: wd.component,
|
|
||||||
defaultWidth: wd.defaultWidth,
|
|
||||||
defaultHeight: wd.defaultHeight,
|
|
||||||
minWidth: wd.minWidth,
|
|
||||||
minHeight: wd.minHeight,
|
|
||||||
maxWidth: wd.maxWidth,
|
|
||||||
maxHeight: wd.maxHeight,
|
|
||||||
configSchema: wd.configSchema,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: wd.name,
|
|
||||||
displayName: wd.displayName,
|
|
||||||
description: wd.description,
|
|
||||||
component: wd.component,
|
|
||||||
defaultWidth: wd.defaultWidth,
|
|
||||||
defaultHeight: wd.defaultHeight,
|
|
||||||
minWidth: wd.minWidth,
|
|
||||||
minHeight: wd.minHeight,
|
|
||||||
maxWidth: wd.maxWidth,
|
|
||||||
maxHeight: wd.maxHeight,
|
|
||||||
configSchema: wd.configSchema,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Seeded ${widgetDefs.length} widget definitions`);
|
|
||||||
|
|
||||||
// Use transaction for atomic seed data reset and creation
|
// Use transaction for atomic seed data reset and creation
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ import { FederationModule } from "./federation/federation.module";
|
|||||||
import { CredentialsModule } from "./credentials/credentials.module";
|
import { CredentialsModule } from "./credentials/credentials.module";
|
||||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||||
import { SpeechModule } from "./speech/speech.module";
|
import { SpeechModule } from "./speech/speech.module";
|
||||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
|
||||||
import { TerminalModule } from "./terminal/terminal.module";
|
|
||||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -104,9 +101,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
CredentialsModule,
|
CredentialsModule,
|
||||||
MosaicTelemetryModule,
|
MosaicTelemetryModule,
|
||||||
SpeechModule,
|
SpeechModule,
|
||||||
DashboardModule,
|
|
||||||
TerminalModule,
|
|
||||||
PersonalitiesModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -254,10 +254,6 @@ export function createAuth(prisma: PrismaClient) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
plugins: [...getOidcPlugins()],
|
plugins: [...getOidcPlugins()],
|
||||||
logger: {
|
|
||||||
disabled: false,
|
|
||||||
level: "error",
|
|
||||||
},
|
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
||||||
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
||||||
|
|||||||
@@ -123,14 +123,6 @@ export class AuthController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
|
|
||||||
if (res.statusCode >= 500) {
|
|
||||||
this.logger.error(
|
|
||||||
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
|
|
||||||
` — check container stdout for '# SERVER_ERROR' details`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
const stack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
|
|||||||
user?: AuthenticatedUser;
|
user?: AuthenticatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller("v1/csrf")
|
@Controller("api/v1/csrf")
|
||||||
export class CsrfController {
|
export class CsrfController {
|
||||||
constructor(private readonly csrfService: CsrfService) {}
|
constructor(private readonly csrfService: CsrfService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -174,19 +174,17 @@ describe("CsrfGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Session binding validation", () => {
|
describe("Session binding validation", () => {
|
||||||
it("should allow when user context is not yet available (global guard ordering)", () => {
|
it("should reject when user is not authenticated", () => {
|
||||||
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
|
|
||||||
// so request.user may not be populated. Double-submit cookie match
|
|
||||||
// is sufficient protection in this case.
|
|
||||||
const token = generateValidToken("user-123");
|
const token = generateValidToken("user-123");
|
||||||
const context = createContext(
|
const context = createContext(
|
||||||
"POST",
|
"POST",
|
||||||
{ "csrf-token": token },
|
{ "csrf-token": token },
|
||||||
{ "x-csrf-token": token },
|
{ "x-csrf-token": token },
|
||||||
false
|
false
|
||||||
// No userId - AuthGuard hasn't run yet
|
// No userId - unauthenticated
|
||||||
);
|
);
|
||||||
expect(guard.canActivate(context)).toBe(true);
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject token from different session", () => {
|
it("should reject token from different session", () => {
|
||||||
|
|||||||
@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
|
|||||||
throw new ForbiddenException("CSRF token mismatch");
|
throw new ForbiddenException("CSRF token mismatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session binding via HMAC when user context is available.
|
// Validate session binding via HMAC
|
||||||
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
|
|
||||||
// AuthGuard, so request.user may not be populated yet. In that case, the
|
|
||||||
// double-submit cookie match above is sufficient CSRF protection.
|
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
if (userId) {
|
if (!userId) {
|
||||||
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
this.logger.warn({
|
||||||
this.logger.warn({
|
event: "CSRF_NO_USER_CONTEXT",
|
||||||
event: "CSRF_SESSION_BINDING_INVALID",
|
|
||||||
method: request.method,
|
|
||||||
path: request.path,
|
|
||||||
securityEvent: true,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new ForbiddenException("CSRF token not bound to session");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.logger.debug({
|
|
||||||
event: "CSRF_SKIP_SESSION_BINDING",
|
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: request.path,
|
path: request.path,
|
||||||
reason: "User context not yet available (global guard runs before AuthGuard)",
|
securityEvent: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
throw new ForbiddenException("CSRF validation requires authentication");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
||||||
|
this.logger.warn({
|
||||||
|
event: "CSRF_SESSION_BINDING_INVALID",
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
securityEvent: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new ForbiddenException("CSRF token not bound to session");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
|
|||||||
return paramWorkspaceId;
|
return paramWorkspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check request body (body may be undefined for GET requests despite Express typings)
|
// 3. Check request body
|
||||||
const body = request.body as Record<string, unknown> | undefined;
|
const bodyWorkspaceId = request.body.workspaceId;
|
||||||
if (body && typeof body.workspaceId === "string") {
|
if (typeof bodyWorkspaceId === "string") {
|
||||||
return body.workspaceId;
|
return bodyWorkspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check query string (backward compatibility for existing clients)
|
// 4. Check query string (backward compatibility for existing clients)
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
|
||||||
import { DashboardController } from "./dashboard.controller";
|
|
||||||
import { DashboardService } from "./dashboard.service";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
||||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
|
||||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
|
||||||
import type { DashboardSummaryDto } from "./dto";
|
|
||||||
|
|
||||||
describe("DashboardController", () => {
|
|
||||||
let controller: DashboardController;
|
|
||||||
let service: DashboardService;
|
|
||||||
|
|
||||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
|
||||||
|
|
||||||
const mockSummary: DashboardSummaryDto = {
|
|
||||||
metrics: {
|
|
||||||
activeAgents: 3,
|
|
||||||
tasksCompleted: 12,
|
|
||||||
totalTasks: 25,
|
|
||||||
tasksInProgress: 5,
|
|
||||||
activeProjects: 4,
|
|
||||||
errorRate: 2.5,
|
|
||||||
},
|
|
||||||
recentActivity: [
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440010",
|
|
||||||
action: "CREATED",
|
|
||||||
entityType: "TASK",
|
|
||||||
entityId: "550e8400-e29b-41d4-a716-446655440011",
|
|
||||||
details: { title: "New task" },
|
|
||||||
userId: "550e8400-e29b-41d4-a716-446655440002",
|
|
||||||
createdAt: "2026-02-22T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
activeJobs: [
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440020",
|
|
||||||
type: "code-task",
|
|
||||||
status: "RUNNING",
|
|
||||||
progressPercent: 45,
|
|
||||||
createdAt: "2026-02-22T11:00:00.000Z",
|
|
||||||
updatedAt: "2026-02-22T11:30:00.000Z",
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440030",
|
|
||||||
name: "Setup",
|
|
||||||
status: "COMPLETED",
|
|
||||||
phase: "SETUP",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tokenBudget: [
|
|
||||||
{
|
|
||||||
model: "agent-1",
|
|
||||||
used: 5000,
|
|
||||||
limit: 10000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDashboardService = {
|
|
||||||
getSummary: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockAuthGuard = {
|
|
||||||
canActivate: vi.fn(() => true),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockWorkspaceGuard = {
|
|
||||||
canActivate: vi.fn(() => true),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPermissionGuard = {
|
|
||||||
canActivate: vi.fn(() => true),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [DashboardController],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: DashboardService,
|
|
||||||
useValue: mockDashboardService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(mockAuthGuard)
|
|
||||||
.overrideGuard(WorkspaceGuard)
|
|
||||||
.useValue(mockWorkspaceGuard)
|
|
||||||
.overrideGuard(PermissionGuard)
|
|
||||||
.useValue(mockPermissionGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
controller = module.get<DashboardController>(DashboardController);
|
|
||||||
service = module.get<DashboardService>(DashboardService);
|
|
||||||
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be defined", () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getSummary", () => {
|
|
||||||
it("should return dashboard summary for workspace", async () => {
|
|
||||||
mockDashboardService.getSummary.mockResolvedValue(mockSummary);
|
|
||||||
|
|
||||||
const result = await controller.getSummary(mockWorkspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockSummary);
|
|
||||||
expect(service.getSummary).toHaveBeenCalledWith(mockWorkspaceId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty arrays when no data exists", async () => {
|
|
||||||
const emptySummary: DashboardSummaryDto = {
|
|
||||||
metrics: {
|
|
||||||
activeAgents: 0,
|
|
||||||
tasksCompleted: 0,
|
|
||||||
totalTasks: 0,
|
|
||||||
tasksInProgress: 0,
|
|
||||||
activeProjects: 0,
|
|
||||||
errorRate: 0,
|
|
||||||
},
|
|
||||||
recentActivity: [],
|
|
||||||
activeJobs: [],
|
|
||||||
tokenBudget: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDashboardService.getSummary.mockResolvedValue(emptySummary);
|
|
||||||
|
|
||||||
const result = await controller.getSummary(mockWorkspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(emptySummary);
|
|
||||||
expect(result.metrics.errorRate).toBe(0);
|
|
||||||
expect(result.recentActivity).toHaveLength(0);
|
|
||||||
expect(result.activeJobs).toHaveLength(0);
|
|
||||||
expect(result.tokenBudget).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common";
|
|
||||||
import { DashboardService } from "./dashboard.service";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
|
||||||
import type { DashboardSummaryDto } from "./dto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for dashboard endpoints.
|
|
||||||
* Returns aggregated summary data for the workspace dashboard.
|
|
||||||
*
|
|
||||||
* Guards are applied in order:
|
|
||||||
* 1. AuthGuard - Verifies user authentication
|
|
||||||
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
|
|
||||||
* 3. PermissionGuard - Checks role-based permissions
|
|
||||||
*/
|
|
||||||
@Controller("dashboard")
|
|
||||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
|
||||||
export class DashboardController {
|
|
||||||
constructor(private readonly dashboardService: DashboardService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/dashboard/summary
|
|
||||||
* Returns aggregated metrics, recent activity, active jobs, and token budgets
|
|
||||||
* Requires: Any workspace member (including GUEST)
|
|
||||||
*/
|
|
||||||
@Get("summary")
|
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
|
||||||
async getSummary(@Workspace() workspaceId: string | undefined): Promise<DashboardSummaryDto> {
|
|
||||||
if (!workspaceId) {
|
|
||||||
throw new BadRequestException("Workspace context required");
|
|
||||||
}
|
|
||||||
return this.dashboardService.getSummary(workspaceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { DashboardController } from "./dashboard.controller";
|
|
||||||
import { DashboardService } from "./dashboard.service";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, AuthModule],
|
|
||||||
controllers: [DashboardController],
|
|
||||||
providers: [DashboardService],
|
|
||||||
exports: [DashboardService],
|
|
||||||
})
|
|
||||||
export class DashboardModule {}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { AgentStatus, ProjectStatus, RunnerJobStatus, TaskStatus } from "@prisma/client";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import type {
|
|
||||||
DashboardSummaryDto,
|
|
||||||
ActiveJobDto,
|
|
||||||
RecentActivityDto,
|
|
||||||
TokenBudgetEntryDto,
|
|
||||||
} from "./dto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for aggregating dashboard summary data.
|
|
||||||
* Executes all queries in parallel to minimize latency.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class DashboardService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get aggregated dashboard summary for a workspace
|
|
||||||
*/
|
|
||||||
async getSummary(workspaceId: string): Promise<DashboardSummaryDto> {
|
|
||||||
const now = new Date();
|
|
||||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
// Execute all queries in parallel
|
|
||||||
const [
|
|
||||||
activeAgents,
|
|
||||||
tasksCompleted,
|
|
||||||
totalTasks,
|
|
||||||
tasksInProgress,
|
|
||||||
activeProjects,
|
|
||||||
failedJobsLast24h,
|
|
||||||
totalJobsLast24h,
|
|
||||||
recentActivityRows,
|
|
||||||
activeJobRows,
|
|
||||||
tokenBudgetRows,
|
|
||||||
] = await Promise.all([
|
|
||||||
// Active agents: IDLE, WORKING, WAITING
|
|
||||||
this.prisma.agent.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: { in: [AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.WAITING] },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Tasks completed
|
|
||||||
this.prisma.task.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: TaskStatus.COMPLETED,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Total tasks
|
|
||||||
this.prisma.task.count({
|
|
||||||
where: { workspaceId },
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Tasks in progress
|
|
||||||
this.prisma.task.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: TaskStatus.IN_PROGRESS,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Active projects
|
|
||||||
this.prisma.project.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: ProjectStatus.ACTIVE,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Failed jobs in last 24h (for error rate)
|
|
||||||
this.prisma.runnerJob.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: RunnerJobStatus.FAILED,
|
|
||||||
createdAt: { gte: oneDayAgo },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Total jobs in last 24h (for error rate)
|
|
||||||
this.prisma.runnerJob.count({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
createdAt: { gte: oneDayAgo },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Recent activity: last 10 entries
|
|
||||||
this.prisma.activityLog.findMany({
|
|
||||||
where: { workspaceId },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: 10,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Active jobs: PENDING, QUEUED, RUNNING with steps
|
|
||||||
this.prisma.runnerJob.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: {
|
|
||||||
in: [RunnerJobStatus.PENDING, RunnerJobStatus.QUEUED, RunnerJobStatus.RUNNING],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
steps: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
status: true,
|
|
||||||
phase: true,
|
|
||||||
},
|
|
||||||
orderBy: { ordinal: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Token budgets for workspace (active, not yet completed)
|
|
||||||
this.prisma.tokenBudget.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
completedAt: null,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
agentId: true,
|
|
||||||
totalTokensUsed: true,
|
|
||||||
allocatedTokens: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Compute error rate
|
|
||||||
const errorRate = totalJobsLast24h > 0 ? (failedJobsLast24h / totalJobsLast24h) * 100 : 0;
|
|
||||||
|
|
||||||
// Map recent activity
|
|
||||||
const recentActivity: RecentActivityDto[] = recentActivityRows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
action: row.action,
|
|
||||||
entityType: row.entityType,
|
|
||||||
entityId: row.entityId,
|
|
||||||
details: row.details as Record<string, unknown> | null,
|
|
||||||
userId: row.userId,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Map active jobs (RunnerJob lacks updatedAt; use startedAt or createdAt as proxy)
|
|
||||||
const activeJobs: ActiveJobDto[] = activeJobRows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
type: row.type,
|
|
||||||
status: row.status,
|
|
||||||
progressPercent: row.progressPercent,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: (row.startedAt ?? row.createdAt).toISOString(),
|
|
||||||
steps: row.steps.map((step) => ({
|
|
||||||
id: step.id,
|
|
||||||
name: step.name,
|
|
||||||
status: step.status,
|
|
||||||
phase: step.phase,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Map token budget entries
|
|
||||||
const tokenBudget: TokenBudgetEntryDto[] = tokenBudgetRows.map((row) => ({
|
|
||||||
model: row.agentId,
|
|
||||||
used: row.totalTokensUsed,
|
|
||||||
limit: row.allocatedTokens,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
metrics: {
|
|
||||||
activeAgents,
|
|
||||||
tasksCompleted,
|
|
||||||
totalTasks,
|
|
||||||
tasksInProgress,
|
|
||||||
activeProjects,
|
|
||||||
errorRate: Math.round(errorRate * 100) / 100,
|
|
||||||
},
|
|
||||||
recentActivity,
|
|
||||||
activeJobs,
|
|
||||||
tokenBudget,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dashboard Summary DTO
|
|
||||||
* Defines the response shape for the dashboard summary endpoint.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class DashboardMetricsDto {
|
|
||||||
activeAgents!: number;
|
|
||||||
tasksCompleted!: number;
|
|
||||||
totalTasks!: number;
|
|
||||||
tasksInProgress!: number;
|
|
||||||
activeProjects!: number;
|
|
||||||
errorRate!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecentActivityDto {
|
|
||||||
id!: string;
|
|
||||||
action!: string;
|
|
||||||
entityType!: string;
|
|
||||||
entityId!: string;
|
|
||||||
details!: Record<string, unknown> | null;
|
|
||||||
userId!: string;
|
|
||||||
createdAt!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ActiveJobStepDto {
|
|
||||||
id!: string;
|
|
||||||
name!: string;
|
|
||||||
status!: string;
|
|
||||||
phase!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ActiveJobDto {
|
|
||||||
id!: string;
|
|
||||||
type!: string;
|
|
||||||
status!: string;
|
|
||||||
progressPercent!: number;
|
|
||||||
createdAt!: string;
|
|
||||||
updatedAt!: string;
|
|
||||||
steps!: ActiveJobStepDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TokenBudgetEntryDto {
|
|
||||||
model!: string;
|
|
||||||
used!: number;
|
|
||||||
limit!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DashboardSummaryDto {
|
|
||||||
metrics!: DashboardMetricsDto;
|
|
||||||
recentActivity!: RecentActivityDto[];
|
|
||||||
activeJobs!: ActiveJobDto[];
|
|
||||||
tokenBudget!: TokenBudgetEntryDto[];
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./dashboard-summary.dto";
|
|
||||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
|||||||
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
||||||
import type { FederationMessageStatus } from "@prisma/client";
|
import type { FederationMessageStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("v1/federation")
|
@Controller("api/v1/federation")
|
||||||
export class CommandController {
|
export class CommandController {
|
||||||
private readonly logger = new Logger(CommandController.name);
|
private readonly logger = new Logger(CommandController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
IncomingEventAckDto,
|
IncomingEventAckDto,
|
||||||
} from "./dto/event.dto";
|
} from "./dto/event.dto";
|
||||||
|
|
||||||
@Controller("v1/federation")
|
@Controller("api/v1/federation")
|
||||||
export class EventController {
|
export class EventController {
|
||||||
private readonly logger = new Logger(EventController.name);
|
private readonly logger = new Logger(EventController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
ValidateFederatedTokenDto,
|
ValidateFederatedTokenDto,
|
||||||
} from "./dto/federated-auth.dto";
|
} from "./dto/federated-auth.dto";
|
||||||
|
|
||||||
@Controller("v1/federation/auth")
|
@Controller("api/v1/federation/auth")
|
||||||
export class FederationAuthController {
|
export class FederationAuthController {
|
||||||
private readonly logger = new Logger(FederationAuthController.name);
|
private readonly logger = new Logger(FederationAuthController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from "./dto/connection.dto";
|
} from "./dto/connection.dto";
|
||||||
import { FederationConnectionStatus } from "@prisma/client";
|
import { FederationConnectionStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("v1/federation")
|
@Controller("api/v1/federation")
|
||||||
export class FederationController {
|
export class FederationController {
|
||||||
private readonly logger = new Logger(FederationController.name);
|
private readonly logger = new Logger(FederationController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
|||||||
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
||||||
import type { FederationMessageStatus } from "@prisma/client";
|
import type { FederationMessageStatus } from "@prisma/client";
|
||||||
|
|
||||||
@Controller("v1/federation")
|
@Controller("api/v1/federation")
|
||||||
export class QueryController {
|
export class QueryController {
|
||||||
private readonly logger = new Logger(QueryController.name);
|
private readonly logger = new Logger(QueryController.name);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator";
|
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { EntryStatus, Visibility } from "@prisma/client";
|
import { EntryStatus } from "@prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for querying knowledge entries (list endpoint)
|
* DTO for querying knowledge entries (list endpoint)
|
||||||
@@ -10,28 +10,10 @@ export class EntryQueryDto {
|
|||||||
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
status?: EntryStatus;
|
status?: EntryStatus;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
|
||||||
visibility?: Visibility;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "tag must be a string" })
|
@IsString({ message: "tag must be a string" })
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString({ message: "search must be a string" })
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["updatedAt", "createdAt", "title"], {
|
|
||||||
message: "sortBy must be updatedAt, createdAt, or title",
|
|
||||||
})
|
|
||||||
sortBy?: "updatedAt" | "createdAt" | "title";
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
|
|
||||||
sortOrder?: "asc" | "desc";
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt({ message: "page must be an integer" })
|
@IsInt({ message: "page must be an integer" })
|
||||||
|
|||||||
@@ -48,10 +48,6 @@ export class KnowledgeService {
|
|||||||
where.status = query.status;
|
where.status = query.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.visibility) {
|
|
||||||
where.visibility = query.visibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.tag) {
|
if (query.tag) {
|
||||||
where.tags = {
|
where.tags = {
|
||||||
some: {
|
some: {
|
||||||
@@ -62,20 +58,6 @@ export class KnowledgeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.search) {
|
|
||||||
where.OR = [
|
|
||||||
{ title: { contains: query.search, mode: "insensitive" } },
|
|
||||||
{ content: { contains: query.search, mode: "insensitive" } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build orderBy
|
|
||||||
const sortField = query.sortBy ?? "updatedAt";
|
|
||||||
const sortDirection = query.sortOrder ?? "desc";
|
|
||||||
const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
|
|
||||||
[sortField]: sortDirection,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
const total = await this.prisma.knowledgeEntry.count({ where });
|
const total = await this.prisma.knowledgeEntry.count({ where });
|
||||||
|
|
||||||
@@ -89,7 +71,9 @@ export class KnowledgeService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy,
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
import { ValidationPipe } from "@nestjs/common";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { getTrustedOrigins } from "./auth/auth.config";
|
import { getTrustedOrigins } from "./auth/auth.config";
|
||||||
@@ -47,16 +47,6 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||||
|
|
||||||
// Set global API prefix — all routes get /api/* except auth and health
|
|
||||||
// Auth routes are excluded because BetterAuth expects /auth/* paths
|
|
||||||
// Health is excluded because Docker healthchecks hit /health directly
|
|
||||||
app.setGlobalPrefix("api", {
|
|
||||||
exclude: [
|
|
||||||
{ path: "health", method: RequestMethod.GET },
|
|
||||||
{ path: "auth/(.*)", method: RequestMethod.ALL },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure CORS for cookie-based authentication
|
// Configure CORS for cookie-based authentication
|
||||||
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
||||||
const trustedOrigins = getTrustedOrigins();
|
const trustedOrigins = getTrustedOrigins();
|
||||||
|
|||||||
@@ -1,38 +1,59 @@
|
|||||||
import { FormalityLevel } from "@prisma/client";
|
import {
|
||||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsInt,
|
||||||
|
IsUUID,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for creating a new personality
|
* DTO for creating a new personality/assistant configuration
|
||||||
* Field names match the frontend API contract from @mosaic/shared Personality type.
|
|
||||||
*/
|
*/
|
||||||
export class CreatePersonalityDto {
|
export class CreatePersonalityDto {
|
||||||
@IsString({ message: "name must be a string" })
|
@IsString()
|
||||||
@MinLength(1, { message: "name must not be empty" })
|
@MinLength(1)
|
||||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
@MaxLength(100)
|
||||||
name!: string;
|
name!: string; // unique identifier slug
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
displayName!: string; // human-readable name
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "description must be a string" })
|
@IsString()
|
||||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
@MaxLength(1000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsString({ message: "tone must be a string" })
|
@IsString()
|
||||||
@MinLength(1, { message: "tone must not be empty" })
|
@MinLength(10)
|
||||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
systemPrompt!: string;
|
||||||
tone!: string;
|
|
||||||
|
|
||||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
|
||||||
formalityLevel!: FormalityLevel;
|
|
||||||
|
|
||||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
|
||||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
|
||||||
systemPromptTemplate!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(2)
|
||||||
|
temperature?: number; // null = use provider default
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxTokens?: number; // null = use provider default
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4")
|
||||||
|
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean({ message: "isActive must be a boolean" })
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
export * from "./create-personality.dto";
|
export * from "./create-personality.dto";
|
||||||
export * from "./update-personality.dto";
|
export * from "./update-personality.dto";
|
||||||
export * from "./personality-query.dto";
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { IsBoolean, IsOptional } from "class-validator";
|
|
||||||
import { Transform } from "class-transformer";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for querying/filtering personalities
|
|
||||||
*/
|
|
||||||
export class PersonalityQueryDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean({ message: "isActive must be a boolean" })
|
|
||||||
@Transform(({ value }) => value === "true" || value === true)
|
|
||||||
isActive?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,42 +1,62 @@
|
|||||||
import { FormalityLevel } from "@prisma/client";
|
import {
|
||||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsInt,
|
||||||
|
IsUUID,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for updating an existing personality
|
* DTO for updating an existing personality/assistant configuration
|
||||||
* All fields are optional; only provided fields are updated.
|
|
||||||
*/
|
*/
|
||||||
export class UpdatePersonalityDto {
|
export class UpdatePersonalityDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "name must be a string" })
|
@IsString()
|
||||||
@MinLength(1, { message: "name must not be empty" })
|
@MinLength(1)
|
||||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
@MaxLength(100)
|
||||||
name?: string;
|
name?: string; // unique identifier slug
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "description must be a string" })
|
@IsString()
|
||||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
displayName?: string; // human-readable name
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "tone must be a string" })
|
@IsString()
|
||||||
@MinLength(1, { message: "tone must not be empty" })
|
@MinLength(10)
|
||||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
systemPrompt?: string;
|
||||||
tone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
@IsNumber()
|
||||||
formalityLevel?: FormalityLevel;
|
@Min(0)
|
||||||
|
@Max(2)
|
||||||
|
temperature?: number; // null = use provider default
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
@IsInt()
|
||||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
@Min(1)
|
||||||
systemPromptTemplate?: string;
|
maxTokens?: number; // null = use provider default
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
@IsUUID("4")
|
||||||
|
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean({ message: "isActive must be a boolean" })
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import type { FormalityLevel } from "@prisma/client";
|
import type { Personality as PrismaPersonality } from "@prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Personality response entity
|
* Personality entity representing an assistant configuration
|
||||||
* Maps Prisma Personality fields to the frontend API contract.
|
|
||||||
*
|
|
||||||
* Field mapping (Prisma -> API):
|
|
||||||
* systemPrompt -> systemPromptTemplate
|
|
||||||
* isEnabled -> isActive
|
|
||||||
* (tone, formalityLevel are identical in both)
|
|
||||||
*/
|
*/
|
||||||
export interface PersonalityResponse {
|
export class Personality implements PrismaPersonality {
|
||||||
id: string;
|
id!: string;
|
||||||
workspaceId: string;
|
workspaceId!: string;
|
||||||
name: string;
|
name!: string; // unique identifier slug
|
||||||
description: string | null;
|
displayName!: string; // human-readable name
|
||||||
tone: string;
|
description!: string | null;
|
||||||
formalityLevel: FormalityLevel;
|
systemPrompt!: string;
|
||||||
systemPromptTemplate: string;
|
temperature!: number | null; // null = use provider default
|
||||||
isDefault: boolean;
|
maxTokens!: number | null; // null = use provider default
|
||||||
isActive: boolean;
|
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
|
||||||
createdAt: Date;
|
isDefault!: boolean;
|
||||||
updatedAt: Date;
|
isEnabled!: boolean;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,36 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PersonalitiesController } from "./personalities.controller";
|
import { PersonalitiesController } from "./personalities.controller";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
||||||
import { FormalityLevel } from "@prisma/client";
|
|
||||||
|
|
||||||
describe("PersonalitiesController", () => {
|
describe("PersonalitiesController", () => {
|
||||||
let controller: PersonalitiesController;
|
let controller: PersonalitiesController;
|
||||||
let service: PersonalitiesService;
|
let service: PersonalitiesService;
|
||||||
|
|
||||||
const mockWorkspaceId = "workspace-123";
|
const mockWorkspaceId = "workspace-123";
|
||||||
|
const mockUserId = "user-123";
|
||||||
const mockPersonalityId = "personality-123";
|
const mockPersonalityId = "personality-123";
|
||||||
|
|
||||||
/** API response shape (frontend field names) */
|
|
||||||
const mockPersonality = {
|
const mockPersonality = {
|
||||||
id: mockPersonalityId,
|
id: mockPersonalityId,
|
||||||
workspaceId: mockWorkspaceId,
|
workspaceId: mockWorkspaceId,
|
||||||
name: "professional-assistant",
|
name: "professional-assistant",
|
||||||
|
displayName: "Professional Assistant",
|
||||||
description: "A professional communication assistant",
|
description: "A professional communication assistant",
|
||||||
tone: "professional",
|
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||||
formalityLevel: FormalityLevel.FORMAL,
|
temperature: 0.7,
|
||||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
maxTokens: 2000,
|
||||||
|
llmProviderInstanceId: "provider-123",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
isActive: true,
|
isEnabled: true,
|
||||||
createdAt: new Date("2026-01-01"),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date("2026-01-01"),
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
user: { id: mockUserId },
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPersonalitiesService = {
|
const mockPersonalitiesService = {
|
||||||
@@ -53,54 +57,24 @@ describe("PersonalitiesController", () => {
|
|||||||
})
|
})
|
||||||
.overrideGuard(AuthGuard)
|
.overrideGuard(AuthGuard)
|
||||||
.useValue({ canActivate: () => true })
|
.useValue({ canActivate: () => true })
|
||||||
.overrideGuard(WorkspaceGuard)
|
|
||||||
.useValue({
|
|
||||||
canActivate: (ctx: {
|
|
||||||
switchToHttp: () => { getRequest: () => { workspaceId: string } };
|
|
||||||
}) => {
|
|
||||||
const req = ctx.switchToHttp().getRequest();
|
|
||||||
req.workspaceId = mockWorkspaceId;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.overrideGuard(PermissionGuard)
|
|
||||||
.useValue({ canActivate: () => true })
|
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findAll", () => {
|
describe("findAll", () => {
|
||||||
it("should return success response with personalities list", async () => {
|
it("should return all personalities", async () => {
|
||||||
const mockList = [mockPersonality];
|
const mockPersonalities = [mockPersonality];
|
||||||
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
|
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
||||||
|
|
||||||
const result = await controller.findAll(mockWorkspaceId, {});
|
const result = await controller.findAll(mockRequest);
|
||||||
|
|
||||||
expect(result).toEqual({ success: true, data: mockList });
|
expect(result).toEqual(mockPersonalities);
|
||||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
|
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||||
});
|
|
||||||
|
|
||||||
it("should pass isActive query filter to service", async () => {
|
|
||||||
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
|
||||||
|
|
||||||
await controller.findAll(mockWorkspaceId, { isActive: true });
|
|
||||||
|
|
||||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findDefault", () => {
|
|
||||||
it("should return the default personality", async () => {
|
|
||||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
|
||||||
|
|
||||||
const result = await controller.findDefault(mockWorkspaceId);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
|
||||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,29 +82,54 @@ describe("PersonalitiesController", () => {
|
|||||||
it("should return a personality by id", async () => {
|
it("should return a personality by id", async () => {
|
||||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
|
const result = await controller.findOne(mockRequest, mockPersonalityId);
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(result).toEqual(mockPersonality);
|
||||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("findByName", () => {
|
||||||
|
it("should return a personality by name", async () => {
|
||||||
|
mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
|
const result = await controller.findByName(mockRequest, "professional-assistant");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockPersonality);
|
||||||
|
expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findDefault", () => {
|
||||||
|
it("should return the default personality", async () => {
|
||||||
|
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
|
const result = await controller.findDefault(mockRequest);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockPersonality);
|
||||||
|
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
it("should create a new personality", async () => {
|
it("should create a new personality", async () => {
|
||||||
const createDto: CreatePersonalityDto = {
|
const createDto: CreatePersonalityDto = {
|
||||||
name: "casual-helper",
|
name: "casual-helper",
|
||||||
|
displayName: "Casual Helper",
|
||||||
description: "A casual helper",
|
description: "A casual helper",
|
||||||
tone: "casual",
|
systemPrompt: "You are a casual assistant.",
|
||||||
formalityLevel: FormalityLevel.CASUAL,
|
temperature: 0.8,
|
||||||
systemPromptTemplate: "You are a casual assistant.",
|
maxTokens: 1500,
|
||||||
};
|
};
|
||||||
|
|
||||||
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
|
mockPersonalitiesService.create.mockResolvedValue({
|
||||||
mockPersonalitiesService.create.mockResolvedValue(created);
|
...mockPersonality,
|
||||||
|
...createDto,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await controller.create(mockWorkspaceId, createDto);
|
const result = await controller.create(mockRequest, createDto);
|
||||||
|
|
||||||
expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
|
expect(result).toMatchObject(createDto);
|
||||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -139,13 +138,15 @@ describe("PersonalitiesController", () => {
|
|||||||
it("should update a personality", async () => {
|
it("should update a personality", async () => {
|
||||||
const updateDto: UpdatePersonalityDto = {
|
const updateDto: UpdatePersonalityDto = {
|
||||||
description: "Updated description",
|
description: "Updated description",
|
||||||
tone: "enthusiastic",
|
temperature: 0.9,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updated = { ...mockPersonality, ...updateDto };
|
mockPersonalitiesService.update.mockResolvedValue({
|
||||||
mockPersonalitiesService.update.mockResolvedValue(updated);
|
...mockPersonality,
|
||||||
|
...updateDto,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
|
||||||
|
|
||||||
expect(result).toMatchObject(updateDto);
|
expect(result).toMatchObject(updateDto);
|
||||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||||
@@ -156,7 +157,7 @@ describe("PersonalitiesController", () => {
|
|||||||
it("should delete a personality", async () => {
|
it("should delete a personality", async () => {
|
||||||
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await controller.delete(mockWorkspaceId, mockPersonalityId);
|
await controller.delete(mockRequest, mockPersonalityId);
|
||||||
|
|
||||||
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||||
});
|
});
|
||||||
@@ -164,10 +165,12 @@ describe("PersonalitiesController", () => {
|
|||||||
|
|
||||||
describe("setDefault", () => {
|
describe("setDefault", () => {
|
||||||
it("should set a personality as default", async () => {
|
it("should set a personality as default", async () => {
|
||||||
const updated = { ...mockPersonality, isDefault: true };
|
mockPersonalitiesService.setDefault.mockResolvedValue({
|
||||||
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
|
...mockPersonality,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
|
const result = await controller.setDefault(mockRequest, mockPersonalityId);
|
||||||
|
|
||||||
expect(result).toMatchObject({ isDefault: true });
|
expect(result).toMatchObject({ isDefault: true });
|
||||||
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|||||||
@@ -6,122 +6,105 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Req,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
|
||||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
import { CreatePersonalityDto } from "./dto/create-personality.dto";
|
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||||
import { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
import { Personality } from "./entities/personality.entity";
|
||||||
import { PersonalityQueryDto } from "./dto/personality-query.dto";
|
|
||||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
interface AuthenticatedRequest {
|
||||||
|
user: { id: string };
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for personality CRUD endpoints.
|
* Controller for managing personality/assistant configurations
|
||||||
* Route: /api/personalities
|
|
||||||
*
|
|
||||||
* Guards applied in order:
|
|
||||||
* 1. AuthGuard - verifies the user is authenticated
|
|
||||||
* 2. WorkspaceGuard - validates workspace access
|
|
||||||
* 3. PermissionGuard - checks role-based permissions
|
|
||||||
*/
|
*/
|
||||||
@Controller("personalities")
|
@Controller("personality")
|
||||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class PersonalitiesController {
|
export class PersonalitiesController {
|
||||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/personalities
|
* List all personalities for the workspace
|
||||||
* List all personalities for the workspace.
|
|
||||||
* Supports ?isActive=true|false filter.
|
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
|
||||||
async findAll(
|
return this.personalitiesService.findAll(req.workspaceId);
|
||||||
@Workspace() workspaceId: string,
|
|
||||||
@Query() query: PersonalityQueryDto
|
|
||||||
): Promise<{ success: true; data: PersonalityResponse[] }> {
|
|
||||||
const data = await this.personalitiesService.findAll(workspaceId, query);
|
|
||||||
return { success: true, data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/personalities/default
|
* Get the default personality for the workspace
|
||||||
* Get the default personality for the workspace.
|
|
||||||
* Must be declared before :id to avoid route conflicts.
|
|
||||||
*/
|
*/
|
||||||
@Get("default")
|
@Get("default")
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
|
||||||
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
|
return this.personalitiesService.findDefault(req.workspaceId);
|
||||||
return this.personalitiesService.findDefault(workspaceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/personalities/:id
|
* Get a personality by its unique name
|
||||||
* Get a single personality by ID.
|
*/
|
||||||
|
@Get("by-name/:name")
|
||||||
|
async findByName(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param("name") name: string
|
||||||
|
): Promise<Personality> {
|
||||||
|
return this.personalitiesService.findByName(req.workspaceId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a personality by ID
|
||||||
*/
|
*/
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
||||||
async findOne(
|
return this.personalitiesService.findOne(req.workspaceId, id);
|
||||||
@Workspace() workspaceId: string,
|
|
||||||
@Param("id") id: string
|
|
||||||
): Promise<PersonalityResponse> {
|
|
||||||
return this.personalitiesService.findOne(workspaceId, id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/personalities
|
* Create a new personality
|
||||||
* Create a new personality.
|
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
|
||||||
async create(
|
async create(
|
||||||
@Workspace() workspaceId: string,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Body() dto: CreatePersonalityDto
|
@Body() dto: CreatePersonalityDto
|
||||||
): Promise<PersonalityResponse> {
|
): Promise<Personality> {
|
||||||
return this.personalitiesService.create(workspaceId, dto);
|
return this.personalitiesService.create(req.workspaceId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/personalities/:id
|
* Update a personality
|
||||||
* Update an existing personality.
|
|
||||||
*/
|
*/
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
|
||||||
async update(
|
async update(
|
||||||
@Workspace() workspaceId: string,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Body() dto: UpdatePersonalityDto
|
@Body() dto: UpdatePersonalityDto
|
||||||
): Promise<PersonalityResponse> {
|
): Promise<Personality> {
|
||||||
return this.personalitiesService.update(workspaceId, id, dto);
|
return this.personalitiesService.update(req.workspaceId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/personalities/:id
|
* Delete a personality
|
||||||
* Delete a personality.
|
|
||||||
*/
|
*/
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
|
||||||
async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> {
|
return this.personalitiesService.delete(req.workspaceId, id);
|
||||||
return this.personalitiesService.delete(workspaceId, id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/personalities/:id/set-default
|
* Set a personality as the default
|
||||||
* Convenience endpoint to set a personality as the default.
|
|
||||||
*/
|
*/
|
||||||
@Post(":id/set-default")
|
@Post(":id/set-default")
|
||||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
|
||||||
async setDefault(
|
async setDefault(
|
||||||
@Workspace() workspaceId: string,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Param("id") id: string
|
@Param("id") id: string
|
||||||
): Promise<PersonalityResponse> {
|
): Promise<Personality> {
|
||||||
return this.personalitiesService.setDefault(workspaceId, id);
|
return this.personalitiesService.setDefault(req.workspaceId, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
|
||||||
import { NotFoundException, ConflictException } from "@nestjs/common";
|
import { NotFoundException, ConflictException } from "@nestjs/common";
|
||||||
import { FormalityLevel } from "@prisma/client";
|
|
||||||
|
|
||||||
describe("PersonalitiesService", () => {
|
describe("PersonalitiesService", () => {
|
||||||
let service: PersonalitiesService;
|
let service: PersonalitiesService;
|
||||||
@@ -13,39 +11,22 @@ describe("PersonalitiesService", () => {
|
|||||||
|
|
||||||
const mockWorkspaceId = "workspace-123";
|
const mockWorkspaceId = "workspace-123";
|
||||||
const mockPersonalityId = "personality-123";
|
const mockPersonalityId = "personality-123";
|
||||||
|
const mockProviderId = "provider-123";
|
||||||
|
|
||||||
/** Raw Prisma record shape (uses Prisma field names) */
|
const mockPersonality = {
|
||||||
const mockPrismaRecord = {
|
|
||||||
id: mockPersonalityId,
|
id: mockPersonalityId,
|
||||||
workspaceId: mockWorkspaceId,
|
workspaceId: mockWorkspaceId,
|
||||||
name: "professional-assistant",
|
name: "professional-assistant",
|
||||||
displayName: "Professional Assistant",
|
displayName: "Professional Assistant",
|
||||||
description: "A professional communication assistant",
|
description: "A professional communication assistant",
|
||||||
tone: "professional",
|
|
||||||
formalityLevel: FormalityLevel.FORMAL,
|
|
||||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
maxTokens: 2000,
|
maxTokens: 2000,
|
||||||
llmProviderInstanceId: "provider-123",
|
llmProviderInstanceId: mockProviderId,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
createdAt: new Date("2026-01-01"),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date("2026-01-01"),
|
updatedAt: new Date(),
|
||||||
};
|
|
||||||
|
|
||||||
/** Expected API response shape (uses frontend field names) */
|
|
||||||
const mockResponse = {
|
|
||||||
id: mockPersonalityId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
name: "professional-assistant",
|
|
||||||
description: "A professional communication assistant",
|
|
||||||
tone: "professional",
|
|
||||||
formalityLevel: FormalityLevel.FORMAL,
|
|
||||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
|
||||||
isDefault: true,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date("2026-01-01"),
|
|
||||||
updatedAt: new Date("2026-01-01"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPrismaService = {
|
const mockPrismaService = {
|
||||||
@@ -56,7 +37,9 @@ describe("PersonalitiesService", () => {
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
},
|
},
|
||||||
|
$transaction: vi.fn((callback) => callback(mockPrismaService)),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -73,54 +56,44 @@ describe("PersonalitiesService", () => {
|
|||||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||||
prisma = module.get<PrismaService>(PrismaService);
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
const createDto: CreatePersonalityDto = {
|
const createDto: CreatePersonalityDto = {
|
||||||
name: "casual-helper",
|
name: "casual-helper",
|
||||||
|
displayName: "Casual Helper",
|
||||||
description: "A casual communication helper",
|
description: "A casual communication helper",
|
||||||
tone: "casual",
|
systemPrompt: "You are a casual assistant.",
|
||||||
formalityLevel: FormalityLevel.CASUAL,
|
temperature: 0.8,
|
||||||
systemPromptTemplate: "You are a casual assistant.",
|
maxTokens: 1500,
|
||||||
isDefault: false,
|
llmProviderInstanceId: mockProviderId,
|
||||||
isActive: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdRecord = {
|
it("should create a new personality", async () => {
|
||||||
...mockPrismaRecord,
|
|
||||||
name: createDto.name,
|
|
||||||
description: createDto.description,
|
|
||||||
tone: createDto.tone,
|
|
||||||
formalityLevel: createDto.formalityLevel,
|
|
||||||
systemPrompt: createDto.systemPromptTemplate,
|
|
||||||
isDefault: false,
|
|
||||||
isEnabled: true,
|
|
||||||
id: "new-personality-id",
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should create a new personality and return API response shape", async () => {
|
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||||
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
|
mockPrismaService.personality.create.mockResolvedValue({
|
||||||
|
...mockPersonality,
|
||||||
|
...createDto,
|
||||||
|
id: "new-personality-id",
|
||||||
|
isDefault: false,
|
||||||
|
isEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await service.create(mockWorkspaceId, createDto);
|
const result = await service.create(mockWorkspaceId, createDto);
|
||||||
|
|
||||||
expect(result.name).toBe(createDto.name);
|
expect(result).toMatchObject(createDto);
|
||||||
expect(result.tone).toBe(createDto.tone);
|
|
||||||
expect(result.formalityLevel).toBe(createDto.formalityLevel);
|
|
||||||
expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
|
|
||||||
expect(result.isActive).toBe(true);
|
|
||||||
expect(result.isDefault).toBe(false);
|
|
||||||
|
|
||||||
expect(prisma.personality.create).toHaveBeenCalledWith({
|
expect(prisma.personality.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
workspaceId: mockWorkspaceId,
|
workspaceId: mockWorkspaceId,
|
||||||
name: createDto.name,
|
name: createDto.name,
|
||||||
displayName: createDto.name,
|
displayName: createDto.displayName,
|
||||||
description: createDto.description ?? null,
|
description: createDto.description ?? null,
|
||||||
tone: createDto.tone,
|
systemPrompt: createDto.systemPrompt,
|
||||||
formalityLevel: createDto.formalityLevel,
|
temperature: createDto.temperature ?? null,
|
||||||
systemPrompt: createDto.systemPromptTemplate,
|
maxTokens: createDto.maxTokens ?? null,
|
||||||
|
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
},
|
},
|
||||||
@@ -128,73 +101,68 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw ConflictException when name already exists", async () => {
|
it("should throw ConflictException when name already exists", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should unset other defaults when creating a new default personality", async () => {
|
it("should unset other defaults when creating a new default personality", async () => {
|
||||||
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
|
const createDefaultDto = { ...createDto, isDefault: true };
|
||||||
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
|
// First call to findFirst checks for name conflict (should be null)
|
||||||
|
// Second call to findFirst finds the existing default personality
|
||||||
mockPrismaService.personality.findFirst
|
mockPrismaService.personality.findFirst
|
||||||
.mockResolvedValueOnce(null) // name conflict check
|
.mockResolvedValueOnce(null) // No name conflict
|
||||||
.mockResolvedValueOnce(otherDefault); // existing default lookup
|
.mockResolvedValueOnce(mockPersonality); // Existing default
|
||||||
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
|
mockPrismaService.personality.update.mockResolvedValue({
|
||||||
|
...mockPersonality,
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
mockPrismaService.personality.create.mockResolvedValue({
|
mockPrismaService.personality.create.mockResolvedValue({
|
||||||
...createdRecord,
|
...mockPersonality,
|
||||||
isDefault: true,
|
...createDefaultDto,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.create(mockWorkspaceId, createDefaultDto);
|
await service.create(mockWorkspaceId, createDefaultDto);
|
||||||
|
|
||||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||||
where: { id: "other-id" },
|
where: { id: mockPersonalityId },
|
||||||
data: { isDefault: false },
|
data: { isDefault: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findAll", () => {
|
describe("findAll", () => {
|
||||||
it("should return mapped response list for a workspace", async () => {
|
it("should return all personalities for a workspace", async () => {
|
||||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
const mockPersonalities = [mockPersonality];
|
||||||
|
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
|
||||||
|
|
||||||
const result = await service.findAll(mockWorkspaceId);
|
const result = await service.findAll(mockWorkspaceId);
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toEqual(mockPersonalities);
|
||||||
expect(result[0]).toEqual(mockResponse);
|
|
||||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||||
where: { workspaceId: mockWorkspaceId },
|
where: { workspaceId: mockWorkspaceId },
|
||||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter by isActive when provided", async () => {
|
|
||||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
|
||||||
|
|
||||||
await service.findAll(mockWorkspaceId, { isActive: true });
|
|
||||||
|
|
||||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
|
||||||
where: { workspaceId: mockWorkspaceId, isEnabled: true },
|
|
||||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findOne", () => {
|
describe("findOne", () => {
|
||||||
it("should return a mapped personality response by id", async () => {
|
it("should return a personality by id", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockPersonality);
|
||||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
|
where: {
|
||||||
|
id: mockPersonalityId,
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
it("should throw NotFoundException when personality not found", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
@@ -203,14 +171,17 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("findByName", () => {
|
describe("findByName", () => {
|
||||||
it("should return a mapped personality response by name", async () => {
|
it("should return a personality by name", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
||||||
|
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockPersonality);
|
||||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||||
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
|
where: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
name: "professional-assistant",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,11 +196,11 @@ describe("PersonalitiesService", () => {
|
|||||||
|
|
||||||
describe("findDefault", () => {
|
describe("findDefault", () => {
|
||||||
it("should return the default personality", async () => {
|
it("should return the default personality", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
const result = await service.findDefault(mockWorkspaceId);
|
const result = await service.findDefault(mockWorkspaceId);
|
||||||
|
|
||||||
expect(result).toEqual(mockResponse);
|
expect(result).toEqual(mockPersonality);
|
||||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||||
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
||||||
});
|
});
|
||||||
@@ -245,45 +216,41 @@ describe("PersonalitiesService", () => {
|
|||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
const updateDto: UpdatePersonalityDto = {
|
const updateDto: UpdatePersonalityDto = {
|
||||||
description: "Updated description",
|
description: "Updated description",
|
||||||
tone: "formal",
|
temperature: 0.9,
|
||||||
isActive: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should update a personality and return mapped response", async () => {
|
it("should update a personality", async () => {
|
||||||
const updatedRecord = {
|
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||||
...mockPrismaRecord,
|
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||||
description: updateDto.description,
|
mockPrismaService.personality.update.mockResolvedValue({
|
||||||
tone: updateDto.tone,
|
...mockPersonality,
|
||||||
isEnabled: false,
|
...updateDto,
|
||||||
};
|
});
|
||||||
|
|
||||||
mockPrismaService.personality.findFirst
|
|
||||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
|
||||||
.mockResolvedValueOnce(null); // name conflict check (no dto.name here)
|
|
||||||
mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
|
|
||||||
|
|
||||||
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||||
|
|
||||||
expect(result.description).toBe(updateDto.description);
|
expect(result).toMatchObject(updateDto);
|
||||||
expect(result.tone).toBe(updateDto.tone);
|
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||||
expect(result.isActive).toBe(false);
|
where: { id: mockPersonalityId },
|
||||||
|
data: updateDto,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
it("should throw NotFoundException when personality not found", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
|
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw ConflictException when updating to an existing name", async () => {
|
it("should throw ConflictException when updating to existing name", async () => {
|
||||||
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
|
const updateNameDto = { name: "existing-name" };
|
||||||
const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
|
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||||
|
mockPrismaService.personality.findFirst.mockResolvedValue({
|
||||||
mockPrismaService.personality.findFirst
|
...mockPersonality,
|
||||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
id: "different-id",
|
||||||
.mockResolvedValueOnce(conflictRecord); // name conflict
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
||||||
@@ -291,16 +258,14 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should unset other defaults when setting as default", async () => {
|
it("should unset other defaults when setting as default", async () => {
|
||||||
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
|
const updateDefaultDto = { isDefault: true };
|
||||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
|
||||||
|
|
||||||
mockPrismaService.personality.findFirst
|
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
|
||||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
|
||||||
mockPrismaService.personality.update
|
mockPrismaService.personality.update
|
||||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||||
.mockResolvedValueOnce(updatedRecord);
|
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
|
||||||
|
|
||||||
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
||||||
|
|
||||||
@@ -308,12 +273,16 @@ describe("PersonalitiesService", () => {
|
|||||||
where: { id: "other-id" },
|
where: { id: "other-id" },
|
||||||
data: { isDefault: false },
|
data: { isDefault: false },
|
||||||
});
|
});
|
||||||
|
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { id: mockPersonalityId },
|
||||||
|
data: updateDefaultDto,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("delete", () => {
|
describe("delete", () => {
|
||||||
it("should delete a personality", async () => {
|
it("should delete a personality", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||||
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.delete(mockWorkspaceId, mockPersonalityId);
|
await service.delete(mockWorkspaceId, mockPersonalityId);
|
||||||
@@ -324,7 +293,7 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
it("should throw NotFoundException when personality not found", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
@@ -334,27 +303,30 @@ describe("PersonalitiesService", () => {
|
|||||||
|
|
||||||
describe("setDefault", () => {
|
describe("setDefault", () => {
|
||||||
it("should set a personality as default", async () => {
|
it("should set a personality as default", async () => {
|
||||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
const updatedPersonality = { ...mockPersonality, isDefault: true };
|
||||||
|
|
||||||
mockPrismaService.personality.findFirst
|
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
|
||||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
|
||||||
mockPrismaService.personality.update
|
mockPrismaService.personality.update
|
||||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||||
.mockResolvedValueOnce(updatedRecord);
|
.mockResolvedValueOnce(updatedPersonality); // Set new default
|
||||||
|
|
||||||
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|
||||||
expect(result.isDefault).toBe(true);
|
expect(result).toMatchObject({ isDefault: true });
|
||||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { id: "other-id" },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||||
where: { id: mockPersonalityId },
|
where: { id: mockPersonalityId },
|
||||||
data: { isDefault: true },
|
data: { isDefault: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
it("should throw NotFoundException when personality not found", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
||||||
import type { FormalityLevel, Personality } from "@prisma/client";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
import { Personality } from "./entities/personality.entity";
|
||||||
import type { PersonalityQueryDto } from "./dto/personality-query.dto";
|
|
||||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing personality/assistant configurations.
|
* Service for managing personality/assistant configurations
|
||||||
*
|
|
||||||
* Field mapping:
|
|
||||||
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
|
|
||||||
* Prisma `isEnabled` <-> API/frontend `isActive`
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonalitiesService {
|
export class PersonalitiesService {
|
||||||
@@ -19,30 +12,11 @@ export class PersonalitiesService {
|
|||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a Prisma Personality record to the API response shape.
|
|
||||||
*/
|
|
||||||
private toResponse(personality: Personality): PersonalityResponse {
|
|
||||||
return {
|
|
||||||
id: personality.id,
|
|
||||||
workspaceId: personality.workspaceId,
|
|
||||||
name: personality.name,
|
|
||||||
description: personality.description,
|
|
||||||
tone: personality.tone,
|
|
||||||
formalityLevel: personality.formalityLevel,
|
|
||||||
systemPromptTemplate: personality.systemPrompt,
|
|
||||||
isDefault: personality.isDefault,
|
|
||||||
isActive: personality.isEnabled,
|
|
||||||
createdAt: personality.createdAt,
|
|
||||||
updatedAt: personality.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new personality
|
* Create a new personality
|
||||||
*/
|
*/
|
||||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
|
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
|
||||||
// Check for duplicate name within workspace
|
// Check for duplicate name
|
||||||
const existing = await this.prisma.personality.findFirst({
|
const existing = await this.prisma.personality.findFirst({
|
||||||
where: { workspaceId, name: dto.name },
|
where: { workspaceId, name: dto.name },
|
||||||
});
|
});
|
||||||
@@ -51,7 +25,7 @@ export class PersonalitiesService {
|
|||||||
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If creating as default, unset other defaults first
|
// If creating a default personality, unset other defaults
|
||||||
if (dto.isDefault) {
|
if (dto.isDefault) {
|
||||||
await this.unsetOtherDefaults(workspaceId);
|
await this.unsetOtherDefaults(workspaceId);
|
||||||
}
|
}
|
||||||
@@ -60,43 +34,36 @@ export class PersonalitiesService {
|
|||||||
data: {
|
data: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
|
displayName: dto.displayName,
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
tone: dto.tone,
|
systemPrompt: dto.systemPrompt,
|
||||||
formalityLevel: dto.formalityLevel,
|
temperature: dto.temperature ?? null,
|
||||||
systemPrompt: dto.systemPromptTemplate,
|
maxTokens: dto.maxTokens ?? null,
|
||||||
|
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
|
||||||
isDefault: dto.isDefault ?? false,
|
isDefault: dto.isDefault ?? false,
|
||||||
isEnabled: dto.isActive ?? true,
|
isEnabled: dto.isEnabled ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
||||||
return this.toResponse(personality);
|
return personality;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all personalities for a workspace with optional active filter
|
* Find all personalities for a workspace
|
||||||
*/
|
*/
|
||||||
async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
|
async findAll(workspaceId: string): Promise<Personality[]> {
|
||||||
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
|
return this.prisma.personality.findMany({
|
||||||
|
where: { workspaceId },
|
||||||
if (query?.isActive !== undefined) {
|
|
||||||
where.isEnabled = query.isActive;
|
|
||||||
}
|
|
||||||
|
|
||||||
const personalities = await this.prisma.personality.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return personalities.map((p) => this.toResponse(p));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a specific personality by ID
|
* Find a specific personality by ID
|
||||||
*/
|
*/
|
||||||
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
async findOne(workspaceId: string, id: string): Promise<Personality> {
|
||||||
const personality = await this.prisma.personality.findFirst({
|
const personality = await this.prisma.personality.findUnique({
|
||||||
where: { id, workspaceId },
|
where: { id, workspaceId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,13 +71,13 @@ export class PersonalitiesService {
|
|||||||
throw new NotFoundException(`Personality with ID ${id} not found`);
|
throw new NotFoundException(`Personality with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResponse(personality);
|
return personality;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a personality by name slug
|
* Find a personality by name
|
||||||
*/
|
*/
|
||||||
async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
|
async findByName(workspaceId: string, name: string): Promise<Personality> {
|
||||||
const personality = await this.prisma.personality.findFirst({
|
const personality = await this.prisma.personality.findFirst({
|
||||||
where: { workspaceId, name },
|
where: { workspaceId, name },
|
||||||
});
|
});
|
||||||
@@ -119,13 +86,13 @@ export class PersonalitiesService {
|
|||||||
throw new NotFoundException(`Personality with name "${name}" not found`);
|
throw new NotFoundException(`Personality with name "${name}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResponse(personality);
|
return personality;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the default (and enabled) personality for a workspace
|
* Find the default personality for a workspace
|
||||||
*/
|
*/
|
||||||
async findDefault(workspaceId: string): Promise<PersonalityResponse> {
|
async findDefault(workspaceId: string): Promise<Personality> {
|
||||||
const personality = await this.prisma.personality.findFirst({
|
const personality = await this.prisma.personality.findFirst({
|
||||||
where: { workspaceId, isDefault: true, isEnabled: true },
|
where: { workspaceId, isDefault: true, isEnabled: true },
|
||||||
});
|
});
|
||||||
@@ -134,18 +101,14 @@ export class PersonalitiesService {
|
|||||||
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResponse(personality);
|
return personality;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing personality
|
* Update an existing personality
|
||||||
*/
|
*/
|
||||||
async update(
|
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
|
||||||
workspaceId: string,
|
// Check existence
|
||||||
id: string,
|
|
||||||
dto: UpdatePersonalityDto
|
|
||||||
): Promise<PersonalityResponse> {
|
|
||||||
// Verify existence
|
|
||||||
await this.findOne(workspaceId, id);
|
await this.findOne(workspaceId, id);
|
||||||
|
|
||||||
// Check for duplicate name if updating name
|
// Check for duplicate name if updating name
|
||||||
@@ -164,43 +127,20 @@ export class PersonalitiesService {
|
|||||||
await this.unsetOtherDefaults(workspaceId, id);
|
await this.unsetOtherDefaults(workspaceId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build update data with field mapping
|
|
||||||
const updateData: {
|
|
||||||
name?: string;
|
|
||||||
displayName?: string;
|
|
||||||
description?: string;
|
|
||||||
tone?: string;
|
|
||||||
formalityLevel?: FormalityLevel;
|
|
||||||
systemPrompt?: string;
|
|
||||||
isDefault?: boolean;
|
|
||||||
isEnabled?: boolean;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
if (dto.name !== undefined) {
|
|
||||||
updateData.name = dto.name;
|
|
||||||
updateData.displayName = dto.name;
|
|
||||||
}
|
|
||||||
if (dto.description !== undefined) updateData.description = dto.description;
|
|
||||||
if (dto.tone !== undefined) updateData.tone = dto.tone;
|
|
||||||
if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
|
|
||||||
if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
|
|
||||||
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
|
|
||||||
if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
|
|
||||||
|
|
||||||
const personality = await this.prisma.personality.update({
|
const personality = await this.prisma.personality.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: dto,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
||||||
return this.toResponse(personality);
|
return personality;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a personality
|
* Delete a personality
|
||||||
*/
|
*/
|
||||||
async delete(workspaceId: string, id: string): Promise<void> {
|
async delete(workspaceId: string, id: string): Promise<void> {
|
||||||
// Verify existence
|
// Check existence
|
||||||
await this.findOne(workspaceId, id);
|
await this.findOne(workspaceId, id);
|
||||||
|
|
||||||
await this.prisma.personality.delete({
|
await this.prisma.personality.delete({
|
||||||
@@ -211,22 +151,23 @@ export class PersonalitiesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a personality as the default (convenience endpoint)
|
* Set a personality as the default
|
||||||
*/
|
*/
|
||||||
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
async setDefault(workspaceId: string, id: string): Promise<Personality> {
|
||||||
// Verify existence
|
// Check existence
|
||||||
await this.findOne(workspaceId, id);
|
await this.findOne(workspaceId, id);
|
||||||
|
|
||||||
// Unset other defaults
|
// Unset other defaults
|
||||||
await this.unsetOtherDefaults(workspaceId, id);
|
await this.unsetOtherDefaults(workspaceId, id);
|
||||||
|
|
||||||
|
// Set this one as default
|
||||||
const personality = await this.prisma.personality.update({
|
const personality = await this.prisma.personality.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isDefault: true },
|
data: { isDefault: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
||||||
return this.toResponse(personality);
|
return personality;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,7 +178,7 @@ export class PersonalitiesService {
|
|||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
...(excludeId !== undefined && { id: { not: excludeId } }),
|
...(excludeId && { id: { not: excludeId } }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -140,11 +140,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
client: PrismaClient = this
|
client: PrismaClient = this
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Use set_config() instead of SET LOCAL so values are safely parameterized.
|
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||||
// SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1
|
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
||||||
// is not supported in SET statements by PostgreSQL).
|
|
||||||
await client.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
|
|
||||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,8 +151,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
* @param client - Optional Prisma client (uses 'this' if not provided)
|
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||||
*/
|
*/
|
||||||
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
||||||
await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`;
|
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
|
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { RunnerJobsService } from "./runner-jobs.service";
|
|||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
import { BullMqModule } from "../bullmq/bullmq.module";
|
import { BullMqModule } from "../bullmq/bullmq.module";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { WebSocketModule } from "../websocket/websocket.module";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runner Jobs Module
|
* Runner Jobs Module
|
||||||
@@ -13,7 +12,7 @@ import { WebSocketModule } from "../websocket/websocket.module";
|
|||||||
* for asynchronous job processing.
|
* for asynchronous job processing.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
|
imports: [PrismaModule, BullMqModule, AuthModule],
|
||||||
controllers: [RunnerJobsController],
|
controllers: [RunnerJobsController],
|
||||||
providers: [RunnerJobsService],
|
providers: [RunnerJobsService],
|
||||||
exports: [RunnerJobsService],
|
exports: [RunnerJobsService],
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { RunnerJobsService } from "./runner-jobs.service";
|
import { RunnerJobsService } from "./runner-jobs.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
|
||||||
import { RunnerJobStatus } from "@prisma/client";
|
import { RunnerJobStatus } from "@prisma/client";
|
||||||
import { ConflictException, BadRequestException } from "@nestjs/common";
|
import { ConflictException, BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -20,12 +19,6 @@ describe("RunnerJobsService - Concurrency", () => {
|
|||||||
getQueue: vi.fn(),
|
getQueue: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockWebSocketGateway = {
|
|
||||||
emitJobCreated: vi.fn(),
|
|
||||||
emitJobStatusChanged: vi.fn(),
|
|
||||||
emitJobProgress: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -44,10 +37,6 @@ describe("RunnerJobsService - Concurrency", () => {
|
|||||||
provide: BullMqService,
|
provide: BullMqService,
|
||||||
useValue: mockBullMqService,
|
useValue: mockBullMqService,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: WebSocketGateway,
|
|
||||||
useValue: mockWebSocketGateway,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { RunnerJobsService } from "./runner-jobs.service";
|
import { RunnerJobsService } from "./runner-jobs.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
|
||||||
import { RunnerJobStatus } from "@prisma/client";
|
import { RunnerJobStatus } from "@prisma/client";
|
||||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||||
import { CreateJobDto, QueryJobsDto } from "./dto";
|
import { CreateJobDto, QueryJobsDto } from "./dto";
|
||||||
@@ -33,12 +32,6 @@ describe("RunnerJobsService", () => {
|
|||||||
getQueue: vi.fn(),
|
getQueue: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockWebSocketGateway = {
|
|
||||||
emitJobCreated: vi.fn(),
|
|
||||||
emitJobStatusChanged: vi.fn(),
|
|
||||||
emitJobProgress: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -51,10 +44,6 @@ describe("RunnerJobsService", () => {
|
|||||||
provide: BullMqService,
|
provide: BullMqService,
|
||||||
useValue: mockBullMqService,
|
useValue: mockBullMqService,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: WebSocketGateway,
|
|
||||||
useValue: mockWebSocketGateway,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
|
|||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { BullMqService } from "../bullmq/bullmq.service";
|
import { BullMqService } from "../bullmq/bullmq.service";
|
||||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
|
||||||
import { QUEUE_NAMES } from "../bullmq/queues";
|
import { QUEUE_NAMES } from "../bullmq/queues";
|
||||||
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
||||||
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||||
@@ -15,8 +14,7 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
|
|||||||
export class RunnerJobsService {
|
export class RunnerJobsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly bullMq: BullMqService,
|
private readonly bullMq: BullMqService
|
||||||
private readonly wsGateway: WebSocketGateway
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,8 +56,6 @@ export class RunnerJobsService {
|
|||||||
{ priority }
|
{ priority }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.wsGateway.emitJobCreated(workspaceId, job);
|
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,13 +194,6 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
status: job.status,
|
|
||||||
previousStatus: existingJob.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
return job;
|
return job;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -259,8 +248,6 @@ export class RunnerJobsService {
|
|||||||
{ priority: existingJob.priority }
|
{ priority: existingJob.priority }
|
||||||
);
|
);
|
||||||
|
|
||||||
this.wsGateway.emitJobCreated(workspaceId, newJob);
|
|
||||||
|
|
||||||
return newJob;
|
return newJob;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,13 +530,6 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
status: updatedJob.status,
|
|
||||||
previousStatus: existingJob.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedJob;
|
return updatedJob;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -626,12 +606,6 @@ export class RunnerJobsService {
|
|||||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.wsGateway.emitJobProgress(workspaceId, id, {
|
|
||||||
id,
|
|
||||||
workspaceId,
|
|
||||||
progressPercent: updatedJob.progressPercent,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedJob;
|
return updatedJob;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Terminal Session DTOs
|
|
||||||
*
|
|
||||||
* Data Transfer Objects for terminal session persistence endpoints.
|
|
||||||
* Validated using class-validator decorators.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
|
|
||||||
import { TerminalSessionStatus } from "@prisma/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for creating a new terminal session record.
|
|
||||||
*/
|
|
||||||
export class CreateTerminalSessionDto {
|
|
||||||
@IsString()
|
|
||||||
@IsUUID()
|
|
||||||
workspaceId!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(128)
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for querying terminal sessions by workspace.
|
|
||||||
*/
|
|
||||||
export class FindTerminalSessionsByWorkspaceDto {
|
|
||||||
@IsString()
|
|
||||||
@IsUUID()
|
|
||||||
workspaceId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response shape for a terminal session.
|
|
||||||
*/
|
|
||||||
export class TerminalSessionResponseDto {
|
|
||||||
id!: string;
|
|
||||||
workspaceId!: string;
|
|
||||||
name!: string;
|
|
||||||
status!: TerminalSessionStatus;
|
|
||||||
createdAt!: Date;
|
|
||||||
closedAt!: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for filtering terminal sessions by status.
|
|
||||||
*/
|
|
||||||
export class TerminalSessionStatusFilterDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(TerminalSessionStatus)
|
|
||||||
status?: TerminalSessionStatus;
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
/**
|
|
||||||
* TerminalSessionService Tests
|
|
||||||
*
|
|
||||||
* Unit tests for database-backed terminal session CRUD:
|
|
||||||
* create, findByWorkspace, close, and findById.
|
|
||||||
* PrismaService is mocked to isolate the service logic.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { NotFoundException } from "@nestjs/common";
|
|
||||||
import { TerminalSessionStatus } from "@prisma/client";
|
|
||||||
import type { TerminalSession } from "@prisma/client";
|
|
||||||
import { TerminalSessionService } from "./terminal-session.service";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
|
|
||||||
return {
|
|
||||||
id: "session-uuid-1",
|
|
||||||
workspaceId: "workspace-uuid-1",
|
|
||||||
name: "Terminal",
|
|
||||||
status: TerminalSessionStatus.ACTIVE,
|
|
||||||
createdAt: new Date("2026-02-25T00:00:00Z"),
|
|
||||||
closedAt: null,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mock PrismaService
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
function makeMockPrisma() {
|
|
||||||
return {
|
|
||||||
terminalSession: {
|
|
||||||
create: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tests
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("TerminalSessionService", () => {
|
|
||||||
let service: TerminalSessionService;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let mockPrisma: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockPrisma = makeMockPrisma();
|
|
||||||
service = new TerminalSessionService(mockPrisma);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// create
|
|
||||||
// ==========================================
|
|
||||||
describe("create", () => {
|
|
||||||
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
|
|
||||||
const session = makeSession();
|
|
||||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
|
||||||
|
|
||||||
const result = await service.create("workspace-uuid-1");
|
|
||||||
|
|
||||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
|
||||||
data: { workspaceId: "workspace-uuid-1" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include name in create data when name is provided", async () => {
|
|
||||||
const session = makeSession({ name: "My Terminal" });
|
|
||||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
|
||||||
|
|
||||||
const result = await service.create("workspace-uuid-1", "My Terminal");
|
|
||||||
|
|
||||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
|
||||||
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return the created session", async () => {
|
|
||||||
const session = makeSession();
|
|
||||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
|
||||||
|
|
||||||
const result = await service.create("workspace-uuid-1");
|
|
||||||
|
|
||||||
expect(result.id).toBe("session-uuid-1");
|
|
||||||
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// findByWorkspace
|
|
||||||
// ==========================================
|
|
||||||
describe("findByWorkspace", () => {
|
|
||||||
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
|
|
||||||
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
|
|
||||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
|
|
||||||
|
|
||||||
const result = await service.findByWorkspace("workspace-uuid-1");
|
|
||||||
|
|
||||||
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
workspaceId: "workspace-uuid-1",
|
|
||||||
status: TerminalSessionStatus.ACTIVE,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return an empty array when no active sessions exist", async () => {
|
|
||||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
const result = await service.findByWorkspace("workspace-uuid-empty");
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not include CLOSED sessions", async () => {
|
|
||||||
// The where clause enforces ACTIVE status — verify it is present
|
|
||||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
|
||||||
|
|
||||||
await service.findByWorkspace("workspace-uuid-1");
|
|
||||||
|
|
||||||
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
|
|
||||||
where: { status: TerminalSessionStatus };
|
|
||||||
};
|
|
||||||
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// close
|
|
||||||
// ==========================================
|
|
||||||
describe("close", () => {
|
|
||||||
it("should set status to CLOSED and set closedAt when session exists", async () => {
|
|
||||||
const existingSession = makeSession();
|
|
||||||
const closedSession = makeSession({
|
|
||||||
status: TerminalSessionStatus.CLOSED,
|
|
||||||
closedAt: new Date("2026-02-25T01:00:00Z"),
|
|
||||||
});
|
|
||||||
|
|
||||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
|
||||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
|
||||||
|
|
||||||
const result = await service.close("session-uuid-1");
|
|
||||||
|
|
||||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { id: "session-uuid-1" },
|
|
||||||
});
|
|
||||||
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: "session-uuid-1" },
|
|
||||||
data: {
|
|
||||||
status: TerminalSessionStatus.CLOSED,
|
|
||||||
closedAt: expect.any(Date),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw NotFoundException when session does not exist", async () => {
|
|
||||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
|
|
||||||
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include a non-null closedAt timestamp on close", async () => {
|
|
||||||
const existingSession = makeSession();
|
|
||||||
const closedSession = makeSession({
|
|
||||||
status: TerminalSessionStatus.CLOSED,
|
|
||||||
closedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
|
||||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
|
||||||
|
|
||||||
const result = await service.close("session-uuid-1");
|
|
||||||
|
|
||||||
expect(result.closedAt).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// findById
|
|
||||||
// ==========================================
|
|
||||||
describe("findById", () => {
|
|
||||||
it("should return the session when it exists", async () => {
|
|
||||||
const session = makeSession();
|
|
||||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
|
|
||||||
|
|
||||||
const result = await service.findById("session-uuid-1");
|
|
||||||
|
|
||||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { id: "session-uuid-1" },
|
|
||||||
});
|
|
||||||
expect(result).toEqual(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return null when session does not exist", async () => {
|
|
||||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
const result = await service.findById("no-such-id");
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
|
|
||||||
const closedSession = makeSession({
|
|
||||||
status: TerminalSessionStatus.CLOSED,
|
|
||||||
closedAt: new Date(),
|
|
||||||
});
|
|
||||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
|
|
||||||
|
|
||||||
const result = await service.findById("session-uuid-1");
|
|
||||||
|
|
||||||
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* TerminalSessionService
|
|
||||||
*
|
|
||||||
* Manages database persistence for terminal sessions.
|
|
||||||
* Provides CRUD operations on the TerminalSession model,
|
|
||||||
* enabling session tracking, recovery, and workspace-level listing.
|
|
||||||
*
|
|
||||||
* Session lifecycle:
|
|
||||||
* - create: record a new terminal session with ACTIVE status
|
|
||||||
* - findByWorkspace: return all ACTIVE sessions for a workspace
|
|
||||||
* - close: mark a session as CLOSED, set closedAt timestamp
|
|
||||||
* - findById: retrieve a single session by ID
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
|
||||||
import { TerminalSessionStatus } from "@prisma/client";
|
|
||||||
import type { TerminalSession } from "@prisma/client";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TerminalSessionService {
|
|
||||||
private readonly logger = new Logger(TerminalSessionService.name);
|
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new terminal session record in the database.
|
|
||||||
*
|
|
||||||
* @param workspaceId - The workspace this session belongs to
|
|
||||||
* @param name - Optional display name for the session (defaults to "Terminal")
|
|
||||||
* @returns The created TerminalSession record
|
|
||||||
*/
|
|
||||||
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
|
|
||||||
this.logger.log(
|
|
||||||
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const data: { workspaceId: string; name?: string } = { workspaceId };
|
|
||||||
if (name !== undefined) {
|
|
||||||
data.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.terminalSession.create({ data });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all ACTIVE terminal sessions for a workspace.
|
|
||||||
*
|
|
||||||
* @param workspaceId - The workspace to query
|
|
||||||
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
|
|
||||||
*/
|
|
||||||
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
|
|
||||||
return this.prisma.terminalSession.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId,
|
|
||||||
status: TerminalSessionStatus.ACTIVE,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close a terminal session by setting its status to CLOSED and recording closedAt.
|
|
||||||
*
|
|
||||||
* @param id - The session ID to close
|
|
||||||
* @returns The updated TerminalSession record
|
|
||||||
* @throws NotFoundException if the session does not exist
|
|
||||||
*/
|
|
||||||
async close(id: string): Promise<TerminalSession> {
|
|
||||||
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
throw new NotFoundException(`Terminal session ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
|
|
||||||
|
|
||||||
return this.prisma.terminalSession.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
status: TerminalSessionStatus.CLOSED,
|
|
||||||
closedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a terminal session by ID.
|
|
||||||
*
|
|
||||||
* @param id - The session ID to retrieve
|
|
||||||
* @returns The TerminalSession record, or null if not found
|
|
||||||
*/
|
|
||||||
async findById(id: string): Promise<TerminalSession | null> {
|
|
||||||
return this.prisma.terminalSession.findUnique({ where: { id } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* Terminal DTOs
|
|
||||||
*
|
|
||||||
* Data Transfer Objects for terminal WebSocket events.
|
|
||||||
* Validated using class-validator decorators.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
IsString,
|
|
||||||
IsOptional,
|
|
||||||
IsNumber,
|
|
||||||
IsInt,
|
|
||||||
Min,
|
|
||||||
Max,
|
|
||||||
MinLength,
|
|
||||||
MaxLength,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for creating a new terminal PTY session.
|
|
||||||
*/
|
|
||||||
export class CreateTerminalDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(128)
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(500)
|
|
||||||
cols?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(200)
|
|
||||||
rows?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(4096)
|
|
||||||
cwd?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for sending input data to a terminal PTY session.
|
|
||||||
*/
|
|
||||||
export class TerminalInputDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(64)
|
|
||||||
sessionId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
data!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for resizing a terminal PTY session.
|
|
||||||
*/
|
|
||||||
export class TerminalResizeDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(64)
|
|
||||||
sessionId!: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(500)
|
|
||||||
cols!: number;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(200)
|
|
||||||
rows!: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for closing a terminal PTY session.
|
|
||||||
*/
|
|
||||||
export class CloseTerminalDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(64)
|
|
||||||
sessionId!: string;
|
|
||||||
}
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
/**
|
|
||||||
* TerminalGateway Tests
|
|
||||||
*
|
|
||||||
* Unit tests for WebSocket terminal gateway:
|
|
||||||
* - Authentication on connection
|
|
||||||
* - terminal:create event handling
|
|
||||||
* - terminal:input event handling
|
|
||||||
* - terminal:resize event handling
|
|
||||||
* - terminal:close event handling
|
|
||||||
* - disconnect cleanup
|
|
||||||
* - Error paths
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
||||||
import type { Socket } from "socket.io";
|
|
||||||
import { TerminalGateway } from "./terminal.gateway";
|
|
||||||
import { TerminalService } from "./terminal.service";
|
|
||||||
import { AuthService } from "../auth/auth.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mocks
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Mock node-pty globally so TerminalService doesn't fail to import
|
|
||||||
vi.mock("node-pty", () => ({
|
|
||||||
spawn: vi.fn(() => ({
|
|
||||||
onData: vi.fn(),
|
|
||||||
onExit: vi.fn(),
|
|
||||||
write: vi.fn(),
|
|
||||||
resize: vi.fn(),
|
|
||||||
kill: vi.fn(),
|
|
||||||
pid: 1000,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
|
||||||
data: {
|
|
||||||
userId?: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
emit: vi.fn(),
|
|
||||||
join: vi.fn(),
|
|
||||||
leave: vi.fn(),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
data: {},
|
|
||||||
handshake: {
|
|
||||||
auth: { token: "valid-token" },
|
|
||||||
query: {},
|
|
||||||
headers: {},
|
|
||||||
},
|
|
||||||
} as unknown as AuthenticatedSocket;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockAuthService() {
|
|
||||||
return {
|
|
||||||
verifySession: vi.fn().mockResolvedValue({
|
|
||||||
user: { id: "user-123" },
|
|
||||||
session: { id: "session-123" },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockPrismaService() {
|
|
||||||
return {
|
|
||||||
workspaceMember: {
|
|
||||||
findFirst: vi.fn().mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockTerminalService() {
|
|
||||||
return {
|
|
||||||
createSession: vi.fn().mockReturnValue({
|
|
||||||
sessionId: "session-uuid-1",
|
|
||||||
name: undefined,
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
}),
|
|
||||||
writeToSession: vi.fn(),
|
|
||||||
resizeSession: vi.fn(),
|
|
||||||
closeSession: vi.fn().mockReturnValue(true),
|
|
||||||
closeWorkspaceSessions: vi.fn(),
|
|
||||||
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
|
|
||||||
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tests
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("TerminalGateway", () => {
|
|
||||||
let gateway: TerminalGateway;
|
|
||||||
let mockAuthService: ReturnType<typeof createMockAuthService>;
|
|
||||||
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
|
|
||||||
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
|
|
||||||
let mockClient: AuthenticatedSocket;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockAuthService = createMockAuthService();
|
|
||||||
mockPrismaService = createMockPrismaService();
|
|
||||||
mockTerminalService = createMockTerminalService();
|
|
||||||
mockClient = createMockSocket();
|
|
||||||
|
|
||||||
gateway = new TerminalGateway(
|
|
||||||
mockAuthService as unknown as AuthService,
|
|
||||||
mockPrismaService as unknown as PrismaService,
|
|
||||||
mockTerminalService as unknown as TerminalService
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// handleConnection (authentication)
|
|
||||||
// ==========================================
|
|
||||||
describe("handleConnection", () => {
|
|
||||||
it("should authenticate client and join workspace room on valid token", async () => {
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({
|
|
||||||
user: { id: "user-123" },
|
|
||||||
});
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
});
|
|
||||||
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
|
|
||||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
|
|
||||||
expect(mockClient.data.userId).toBe("user-123");
|
|
||||||
expect(mockClient.data.workspaceId).toBe("workspace-456");
|
|
||||||
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disconnect and emit error if no token provided", async () => {
|
|
||||||
const clientNoToken = createMockSocket("no-token");
|
|
||||||
clientNoToken.handshake = {
|
|
||||||
auth: {},
|
|
||||||
query: {},
|
|
||||||
headers: {},
|
|
||||||
} as typeof clientNoToken.handshake;
|
|
||||||
|
|
||||||
await gateway.handleConnection(clientNoToken);
|
|
||||||
|
|
||||||
expect(clientNoToken.disconnect).toHaveBeenCalled();
|
|
||||||
expect(clientNoToken.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("no token") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disconnect and emit error if token is invalid", async () => {
|
|
||||||
mockAuthService.verifySession.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
|
|
||||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("invalid") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disconnect and emit error if no workspace access", async () => {
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
|
|
||||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("workspace") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disconnect and emit error if auth throws", async () => {
|
|
||||||
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
|
|
||||||
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
|
|
||||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.any(String) })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract token from handshake.query as fallback", async () => {
|
|
||||||
const clientQueryToken = createMockSocket("query-token-client");
|
|
||||||
clientQueryToken.handshake = {
|
|
||||||
auth: {},
|
|
||||||
query: { token: "query-token" },
|
|
||||||
headers: {},
|
|
||||||
} as typeof clientQueryToken.handshake;
|
|
||||||
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
});
|
|
||||||
|
|
||||||
await gateway.handleConnection(clientQueryToken);
|
|
||||||
|
|
||||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract token from Authorization header as last fallback", async () => {
|
|
||||||
const clientHeaderToken = createMockSocket("header-token-client");
|
|
||||||
clientHeaderToken.handshake = {
|
|
||||||
auth: {},
|
|
||||||
query: {},
|
|
||||||
headers: { authorization: "Bearer header-token" },
|
|
||||||
} as typeof clientHeaderToken.handshake;
|
|
||||||
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
});
|
|
||||||
|
|
||||||
await gateway.handleConnection(clientHeaderToken);
|
|
||||||
|
|
||||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// handleDisconnect
|
|
||||||
// ==========================================
|
|
||||||
describe("handleDisconnect", () => {
|
|
||||||
it("should close all workspace sessions on disconnect", async () => {
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
gateway.handleDisconnect(mockClient);
|
|
||||||
|
|
||||||
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw for unauthenticated client disconnect", () => {
|
|
||||||
const unauthClient = createMockSocket("unauth-disconnect");
|
|
||||||
|
|
||||||
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
|
|
||||||
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// handleCreate (terminal:create)
|
|
||||||
// ==========================================
|
|
||||||
describe("handleCreate", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
});
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a PTY session and emit terminal:created", async () => {
|
|
||||||
mockTerminalService.createSession.mockReturnValue({
|
|
||||||
sessionId: "new-session-id",
|
|
||||||
cols: 80,
|
|
||||||
rows: 24,
|
|
||||||
});
|
|
||||||
|
|
||||||
await gateway.handleCreate(mockClient, {});
|
|
||||||
|
|
||||||
expect(mockTerminalService.createSession).toHaveBeenCalled();
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:created",
|
|
||||||
expect.objectContaining({ sessionId: "new-session-id" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should pass cols, rows, cwd, name to service", async () => {
|
|
||||||
await gateway.handleCreate(mockClient, {
|
|
||||||
cols: 132,
|
|
||||||
rows: 50,
|
|
||||||
cwd: "/home/user",
|
|
||||||
name: "my-shell",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error if not authenticated", async () => {
|
|
||||||
const unauthClient = createMockSocket("unauth");
|
|
||||||
|
|
||||||
await gateway.handleCreate(unauthClient, {});
|
|
||||||
|
|
||||||
expect(unauthClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error if service throws (session limit)", async () => {
|
|
||||||
mockTerminalService.createSession.mockImplementation(() => {
|
|
||||||
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
|
|
||||||
});
|
|
||||||
|
|
||||||
await gateway.handleCreate(mockClient, {});
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("maximum") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error for invalid payload (negative cols)", async () => {
|
|
||||||
await gateway.handleCreate(mockClient, { cols: -1 });
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// handleInput (terminal:input)
|
|
||||||
// ==========================================
|
|
||||||
describe("handleInput", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
});
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should write data to the PTY session", async () => {
|
|
||||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
||||||
|
|
||||||
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
|
|
||||||
|
|
||||||
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
|
||||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
|
||||||
|
|
||||||
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
||||||
);
|
|
||||||
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error if not authenticated", async () => {
|
|
||||||
const unauthClient = createMockSocket("unauth");
|
|
||||||
|
|
||||||
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
|
|
||||||
|
|
||||||
expect(unauthClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
|
||||||
await gateway.handleInput(mockClient, { data: "some input" });
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// handleResize (terminal:resize)
|
|
||||||
// ==========================================
|
|
||||||
describe("handleResize", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
});
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should resize the PTY session", async () => {
|
|
||||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
||||||
|
|
||||||
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
|
|
||||||
|
|
||||||
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
|
||||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
|
||||||
|
|
||||||
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error for invalid payload (cols too large)", async () => {
|
|
||||||
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// handleClose (terminal:close)
|
|
||||||
// ==========================================
|
|
||||||
describe("handleClose", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
|
||||||
userId: "user-123",
|
|
||||||
workspaceId: "workspace-456",
|
|
||||||
role: "MEMBER",
|
|
||||||
});
|
|
||||||
await gateway.handleConnection(mockClient);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should close an existing PTY session", async () => {
|
|
||||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
||||||
mockTerminalService.closeSession.mockReturnValue(true);
|
|
||||||
|
|
||||||
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
|
|
||||||
|
|
||||||
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
|
||||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
|
||||||
|
|
||||||
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
|
|
||||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
|
||||||
mockTerminalService.closeSession.mockReturnValue(false);
|
|
||||||
|
|
||||||
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
|
||||||
await gateway.handleClose(mockClient, {});
|
|
||||||
|
|
||||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
|
||||||
"terminal:error",
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
/**
|
|
||||||
* TerminalGateway
|
|
||||||
*
|
|
||||||
* WebSocket gateway for real-time PTY terminal sessions.
|
|
||||||
* Uses the `/terminal` namespace to keep terminal traffic separate
|
|
||||||
* from the main WebSocket gateway.
|
|
||||||
*
|
|
||||||
* Protocol:
|
|
||||||
* 1. Client connects with auth token in handshake
|
|
||||||
* 2. Client emits `terminal:create` to spawn a new PTY session
|
|
||||||
* 3. Server emits `terminal:created` with { sessionId }
|
|
||||||
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
|
|
||||||
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
|
|
||||||
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
|
|
||||||
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
|
|
||||||
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
|
|
||||||
*
|
|
||||||
* Authentication:
|
|
||||||
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
|
|
||||||
* - Token extracted from handshake.auth.token / query.token / Authorization header
|
|
||||||
*
|
|
||||||
* Workspace isolation:
|
|
||||||
* - Clients join room `terminal:{workspaceId}` on connect
|
|
||||||
* - Sessions are scoped to workspace; cross-workspace access is denied
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
WebSocketGateway as WSGateway,
|
|
||||||
WebSocketServer,
|
|
||||||
SubscribeMessage,
|
|
||||||
OnGatewayConnection,
|
|
||||||
OnGatewayDisconnect,
|
|
||||||
} from "@nestjs/websockets";
|
|
||||||
import { Logger } from "@nestjs/common";
|
|
||||||
import { Server, Socket } from "socket.io";
|
|
||||||
import { AuthService } from "../auth/auth.service";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import { TerminalService } from "./terminal.service";
|
|
||||||
import {
|
|
||||||
CreateTerminalDto,
|
|
||||||
TerminalInputDto,
|
|
||||||
TerminalResizeDto,
|
|
||||||
CloseTerminalDto,
|
|
||||||
} from "./terminal.dto";
|
|
||||||
import { validate } from "class-validator";
|
|
||||||
import { plainToInstance } from "class-transformer";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Types
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
|
||||||
data: {
|
|
||||||
userId?: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Gateway
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
@WSGateway({
|
|
||||||
namespace: "/terminal",
|
|
||||||
cors: {
|
|
||||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
|
||||||
credentials: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
||||||
@WebSocketServer()
|
|
||||||
server!: Server;
|
|
||||||
|
|
||||||
private readonly logger = new Logger(TerminalGateway.name);
|
|
||||||
private readonly CONNECTION_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly terminalService: TerminalService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Connection lifecycle
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate client on connection using handshake token.
|
|
||||||
* Validates workspace membership and joins the workspace-scoped room.
|
|
||||||
*/
|
|
||||||
async handleConnection(client: Socket): Promise<void> {
|
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (!authenticatedClient.data.userId) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Terminal client ${authenticatedClient.id} timed out during authentication`
|
|
||||||
);
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Authentication timed out.",
|
|
||||||
});
|
|
||||||
authenticatedClient.disconnect();
|
|
||||||
}
|
|
||||||
}, this.CONNECTION_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = this.extractTokenFromHandshake(authenticatedClient);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Authentication failed: no token provided.",
|
|
||||||
});
|
|
||||||
authenticatedClient.disconnect();
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionData = await this.authService.verifySession(token);
|
|
||||||
|
|
||||||
if (!sessionData) {
|
|
||||||
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Authentication failed: invalid or expired token.",
|
|
||||||
});
|
|
||||||
authenticatedClient.disconnect();
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = sessionData.user as { id: string };
|
|
||||||
const userId = user.id;
|
|
||||||
|
|
||||||
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
|
|
||||||
where: { userId },
|
|
||||||
select: { workspaceId: true, userId: true, role: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workspaceMembership) {
|
|
||||||
this.logger.warn(`Terminal user ${userId} has no workspace access`);
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Authentication failed: no workspace access.",
|
|
||||||
});
|
|
||||||
authenticatedClient.disconnect();
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
authenticatedClient.data.userId = userId;
|
|
||||||
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
|
|
||||||
|
|
||||||
// Join workspace-scoped terminal room
|
|
||||||
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
|
|
||||||
await authenticatedClient.join(room);
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
this.logger.log(
|
|
||||||
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
this.logger.error(
|
|
||||||
`Authentication failed for terminal client ${authenticatedClient.id}:`,
|
|
||||||
error instanceof Error ? error.message : "Unknown error"
|
|
||||||
);
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Authentication failed: an unexpected error occurred.",
|
|
||||||
});
|
|
||||||
authenticatedClient.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up all PTY sessions for this client's workspace on disconnect.
|
|
||||||
*/
|
|
||||||
handleDisconnect(client: Socket): void {
|
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
|
||||||
const { workspaceId, userId } = authenticatedClient.data;
|
|
||||||
|
|
||||||
if (workspaceId) {
|
|
||||||
this.terminalService.closeWorkspaceSessions(workspaceId);
|
|
||||||
|
|
||||||
const room = this.getWorkspaceRoom(workspaceId);
|
|
||||||
void authenticatedClient.leave(room);
|
|
||||||
this.logger.log(
|
|
||||||
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Terminal events
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawn a new PTY session for the connected client.
|
|
||||||
*
|
|
||||||
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
|
|
||||||
* Emits `terminal:error` on failure.
|
|
||||||
*/
|
|
||||||
@SubscribeMessage("terminal:create")
|
|
||||||
async handleCreate(client: Socket, payload: unknown): Promise<void> {
|
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
|
||||||
const { userId, workspaceId } = authenticatedClient.data;
|
|
||||||
|
|
||||||
if (!userId || !workspaceId) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Not authenticated. Connect with a valid token.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate DTO
|
|
||||||
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
|
|
||||||
const errors = await validate(dto);
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Invalid payload: ${messages}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = this.terminalService.createSession(authenticatedClient, {
|
|
||||||
workspaceId,
|
|
||||||
socketId: authenticatedClient.id,
|
|
||||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
|
||||||
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
|
|
||||||
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
|
|
||||||
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
authenticatedClient.emit("terminal:created", {
|
|
||||||
sessionId: result.sessionId,
|
|
||||||
name: result.name,
|
|
||||||
cols: result.cols,
|
|
||||||
rows: result.rows,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
|
|
||||||
);
|
|
||||||
authenticatedClient.emit("terminal:error", { message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write input data to an existing PTY session.
|
|
||||||
*
|
|
||||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
|
||||||
*/
|
|
||||||
@SubscribeMessage("terminal:input")
|
|
||||||
async handleInput(client: Socket, payload: unknown): Promise<void> {
|
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
|
||||||
const { userId, workspaceId } = authenticatedClient.data;
|
|
||||||
|
|
||||||
if (!userId || !workspaceId) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Not authenticated. Connect with a valid token.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto = plainToInstance(TerminalInputDto, payload ?? {});
|
|
||||||
const errors = await validate(dto);
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Invalid payload: ${messages}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.terminalService.writeToSession(dto.sessionId, dto.data);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
|
|
||||||
authenticatedClient.emit("terminal:error", { message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize an existing PTY session.
|
|
||||||
*
|
|
||||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
|
||||||
*/
|
|
||||||
@SubscribeMessage("terminal:resize")
|
|
||||||
async handleResize(client: Socket, payload: unknown): Promise<void> {
|
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
|
||||||
const { userId, workspaceId } = authenticatedClient.data;
|
|
||||||
|
|
||||||
if (!userId || !workspaceId) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Not authenticated. Connect with a valid token.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
|
|
||||||
const errors = await validate(dto);
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Invalid payload: ${messages}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
|
|
||||||
authenticatedClient.emit("terminal:error", { message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill and close an existing PTY session.
|
|
||||||
*
|
|
||||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
|
||||||
*/
|
|
||||||
@SubscribeMessage("terminal:close")
|
|
||||||
async handleClose(client: Socket, payload: unknown): Promise<void> {
|
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
|
||||||
const { userId, workspaceId } = authenticatedClient.data;
|
|
||||||
|
|
||||||
if (!userId || !workspaceId) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: "Not authenticated. Connect with a valid token.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
|
|
||||||
const errors = await validate(dto);
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Invalid payload: ${messages}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const closed = this.terminalService.closeSession(dto.sessionId);
|
|
||||||
if (!closed) {
|
|
||||||
authenticatedClient.emit("terminal:error", {
|
|
||||||
message: `Terminal session ${dto.sessionId} not found.`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Private helpers
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract authentication token from Socket.IO handshake.
|
|
||||||
* Checks auth.token, query.token, and Authorization header (in that order).
|
|
||||||
*/
|
|
||||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
|
||||||
const authToken = client.handshake.auth.token as unknown;
|
|
||||||
if (typeof authToken === "string" && authToken.length > 0) {
|
|
||||||
return authToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryToken = client.handshake.query.token as unknown;
|
|
||||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
|
||||||
return queryToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeader = client.handshake.headers.authorization as unknown;
|
|
||||||
if (typeof authHeader === "string") {
|
|
||||||
const parts = authHeader.split(" ");
|
|
||||||
const [type, token] = parts;
|
|
||||||
if (type === "Bearer" && token) {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the workspace-scoped room name for the terminal namespace.
|
|
||||||
*/
|
|
||||||
private getWorkspaceRoom(workspaceId: string): string {
|
|
||||||
return `terminal:${workspaceId}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* TerminalModule
|
|
||||||
*
|
|
||||||
* NestJS module for WebSocket-based terminal sessions via node-pty.
|
|
||||||
*
|
|
||||||
* Imports:
|
|
||||||
* - AuthModule for WebSocket authentication (verifySession)
|
|
||||||
* - PrismaModule for workspace membership queries and session persistence
|
|
||||||
*
|
|
||||||
* Providers:
|
|
||||||
* - TerminalService: manages PTY session lifecycle (in-memory)
|
|
||||||
* - TerminalSessionService: persists session records to the database
|
|
||||||
* - TerminalGateway: WebSocket gateway on /terminal namespace
|
|
||||||
*
|
|
||||||
* The module does not export providers; terminal sessions are
|
|
||||||
* self-contained within this module.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { TerminalGateway } from "./terminal.gateway";
|
|
||||||
import { TerminalService } from "./terminal.service";
|
|
||||||
import { TerminalSessionService } from "./terminal-session.service";
|
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [AuthModule, PrismaModule],
|
|
||||||
providers: [TerminalGateway, TerminalService, TerminalSessionService],
|
|
||||||
exports: [TerminalSessionService],
|
|
||||||
})
|
|
||||||
export class TerminalModule {}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
/**
|
|
||||||
* TerminalService Tests
|
|
||||||
*
|
|
||||||
* Unit tests for PTY session management: create, write, resize, close,
|
|
||||||
* workspace cleanup, and access control.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
||||||
import type { Socket } from "socket.io";
|
|
||||||
import { TerminalService, MAX_SESSIONS_PER_WORKSPACE } from "./terminal.service";
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Mocks
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Mock node-pty before importing service
|
|
||||||
const mockPtyProcess = {
|
|
||||||
onData: vi.fn(),
|
|
||||||
onExit: vi.fn(),
|
|
||||||
write: vi.fn(),
|
|
||||||
resize: vi.fn(),
|
|
||||||
kill: vi.fn(),
|
|
||||||
pid: 12345,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("node-pty", () => ({
|
|
||||||
spawn: vi.fn(() => mockPtyProcess),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createMockSocket(id = "socket-1"): Socket {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
emit: vi.fn(),
|
|
||||||
join: vi.fn(),
|
|
||||||
leave: vi.fn(),
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
data: {},
|
|
||||||
} as unknown as Socket;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Tests
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
describe("TerminalService", () => {
|
|
||||||
let service: TerminalService;
|
|
||||||
let mockSocket: Socket;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
// Reset mock implementations
|
|
||||||
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
|
||||||
mockPtyProcess.onExit.mockImplementation(
|
|
||||||
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
|
||||||
);
|
|
||||||
service = new TerminalService();
|
|
||||||
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
|
|
||||||
await service.onModuleInit();
|
|
||||||
mockSocket = createMockSocket();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// createSession
|
|
||||||
// ==========================================
|
|
||||||
describe("createSession", () => {
|
|
||||||
it("should create a PTY session and return sessionId", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.sessionId).toBeDefined();
|
|
||||||
expect(typeof result.sessionId).toBe("string");
|
|
||||||
expect(result.cols).toBe(80);
|
|
||||||
expect(result.rows).toBe(24);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use provided cols and rows", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
cols: 120,
|
|
||||||
rows: 40,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.cols).toBe(120);
|
|
||||||
expect(result.rows).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return the provided session name", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
name: "my-terminal",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.name).toBe("my-terminal");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should wire PTY onData to emit terminal:output", () => {
|
|
||||||
let dataCallback: ((data: string) => void) | undefined;
|
|
||||||
mockPtyProcess.onData.mockImplementation((cb: (data: string) => void) => {
|
|
||||||
dataCallback = cb;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dataCallback).toBeDefined();
|
|
||||||
dataCallback!("hello world");
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:output", {
|
|
||||||
sessionId: result.sessionId,
|
|
||||||
data: "hello world",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should wire PTY onExit to emit terminal:exit and cleanup", () => {
|
|
||||||
let exitCallback: ((e: { exitCode: number; signal?: number }) => void) | undefined;
|
|
||||||
mockPtyProcess.onExit.mockImplementation(
|
|
||||||
(cb: (e: { exitCode: number; signal?: number }) => void) => {
|
|
||||||
exitCallback = cb;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(exitCallback).toBeDefined();
|
|
||||||
exitCallback!({ exitCode: 0 });
|
|
||||||
|
|
||||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:exit", {
|
|
||||||
sessionId: result.sessionId,
|
|
||||||
exitCode: 0,
|
|
||||||
signal: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Session should be cleaned up
|
|
||||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw when workspace session limit is reached", () => {
|
|
||||||
const limit = MAX_SESSIONS_PER_WORKSPACE;
|
|
||||||
|
|
||||||
for (let i = 0; i < limit; i++) {
|
|
||||||
service.createSession(createMockSocket(`socket-${String(i)}`), {
|
|
||||||
workspaceId: "ws-limit",
|
|
||||||
socketId: `socket-${String(i)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
service.createSession(createMockSocket("socket-overflow"), {
|
|
||||||
workspaceId: "ws-limit",
|
|
||||||
socketId: "socket-overflow",
|
|
||||||
})
|
|
||||||
).toThrow(/maximum/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow sessions in different workspaces independently", () => {
|
|
||||||
service.createSession(mockSocket, { workspaceId: "ws-a", socketId: "s1" });
|
|
||||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-b", socketId: "s2" });
|
|
||||||
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-a")).toBe(1);
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-b")).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// writeToSession
|
|
||||||
// ==========================================
|
|
||||||
describe("writeToSession", () => {
|
|
||||||
it("should write data to PTY", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
service.writeToSession(result.sessionId, "ls -la\n");
|
|
||||||
|
|
||||||
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw for unknown sessionId", () => {
|
|
||||||
expect(() => service.writeToSession("nonexistent-id", "data")).toThrow(/not found/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// resizeSession
|
|
||||||
// ==========================================
|
|
||||||
describe("resizeSession", () => {
|
|
||||||
it("should resize PTY dimensions", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
service.resizeSession(result.sessionId, 132, 50);
|
|
||||||
|
|
||||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(132, 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw for unknown sessionId", () => {
|
|
||||||
expect(() => service.resizeSession("nonexistent-id", 80, 24)).toThrow(/not found/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// closeSession
|
|
||||||
// ==========================================
|
|
||||||
describe("closeSession", () => {
|
|
||||||
it("should kill PTY and return true for existing session", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const closed = service.closeSession(result.sessionId);
|
|
||||||
|
|
||||||
expect(closed).toBe(true);
|
|
||||||
expect(mockPtyProcess.kill).toHaveBeenCalled();
|
|
||||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for nonexistent sessionId", () => {
|
|
||||||
const closed = service.closeSession("does-not-exist");
|
|
||||||
expect(closed).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clean up workspace tracking after close", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(1);
|
|
||||||
service.closeSession(result.sessionId);
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw if PTY kill throws", () => {
|
|
||||||
mockPtyProcess.kill.mockImplementationOnce(() => {
|
|
||||||
throw new Error("PTY already dead");
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => service.closeSession(result.sessionId)).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// closeWorkspaceSessions
|
|
||||||
// ==========================================
|
|
||||||
describe("closeWorkspaceSessions", () => {
|
|
||||||
it("should kill all sessions for a workspace", () => {
|
|
||||||
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
|
||||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-1", socketId: "s2" });
|
|
||||||
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(2);
|
|
||||||
|
|
||||||
service.closeWorkspaceSessions("ws-1");
|
|
||||||
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
|
||||||
expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect sessions in other workspaces", () => {
|
|
||||||
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
|
||||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-2", socketId: "s2" });
|
|
||||||
|
|
||||||
service.closeWorkspaceSessions("ws-1");
|
|
||||||
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-2")).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw for workspaces with no sessions", () => {
|
|
||||||
expect(() => service.closeWorkspaceSessions("ws-nonexistent")).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// sessionBelongsToWorkspace
|
|
||||||
// ==========================================
|
|
||||||
describe("sessionBelongsToWorkspace", () => {
|
|
||||||
it("should return true for a session belonging to the workspace", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for a session in a different workspace", () => {
|
|
||||||
const result = service.createSession(mockSocket, {
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
socketId: "socket-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-2")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false for a nonexistent sessionId", () => {
|
|
||||||
expect(service.sessionBelongsToWorkspace("no-such-id", "ws-1")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// getWorkspaceSessionCount
|
|
||||||
// ==========================================
|
|
||||||
describe("getWorkspaceSessionCount", () => {
|
|
||||||
it("should return 0 for workspace with no sessions", () => {
|
|
||||||
expect(service.getWorkspaceSessionCount("empty-ws")).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should track session count accurately", () => {
|
|
||||||
service.createSession(mockSocket, { workspaceId: "ws-count", socketId: "s1" });
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-count")).toBe(1);
|
|
||||||
|
|
||||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-count", socketId: "s2" });
|
|
||||||
expect(service.getWorkspaceSessionCount("ws-count")).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
/**
|
|
||||||
* TerminalService
|
|
||||||
*
|
|
||||||
* Manages PTY (pseudo-terminal) sessions for workspace users.
|
|
||||||
* Spawns real shell processes via node-pty, streams I/O to connected sockets,
|
|
||||||
* and enforces per-workspace session limits.
|
|
||||||
*
|
|
||||||
* Session lifecycle:
|
|
||||||
* - createSession: spawn a new PTY, wire onData/onExit, return sessionId
|
|
||||||
* - writeToSession: send input data to PTY stdin
|
|
||||||
* - resizeSession: resize PTY dimensions (cols x rows)
|
|
||||||
* - closeSession: kill PTY process, emit terminal:exit, cleanup
|
|
||||||
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
|
||||||
import type { IPty } from "node-pty";
|
|
||||||
import type { Socket } from "socket.io";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
|
|
||||||
// if the native binary is missing. node-pty requires a compiled .node
|
|
||||||
// binary which may not be available in all Docker environments.
|
|
||||||
interface NodePtyModule {
|
|
||||||
spawn: (file: string, args: string[], options: Record<string, unknown>) => IPty;
|
|
||||||
}
|
|
||||||
let pty: NodePtyModule | null = null;
|
|
||||||
|
|
||||||
/** Maximum concurrent PTY sessions per workspace */
|
|
||||||
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
|
||||||
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
|
||||||
10
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Default PTY dimensions */
|
|
||||||
const DEFAULT_COLS = 80;
|
|
||||||
const DEFAULT_ROWS = 24;
|
|
||||||
|
|
||||||
export interface TerminalSession {
|
|
||||||
sessionId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
pty: IPty;
|
|
||||||
name?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateSessionOptions {
|
|
||||||
name?: string;
|
|
||||||
cols?: number;
|
|
||||||
rows?: number;
|
|
||||||
cwd?: string;
|
|
||||||
workspaceId: string;
|
|
||||||
socketId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionCreatedResult {
|
|
||||||
sessionId: string;
|
|
||||||
name?: string;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TerminalService implements OnModuleInit {
|
|
||||||
private readonly logger = new Logger(TerminalService.name);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of sessionId -> TerminalSession
|
|
||||||
*/
|
|
||||||
private readonly sessions = new Map<string, TerminalSession>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map of workspaceId -> Set<sessionId> for fast per-workspace lookups
|
|
||||||
*/
|
|
||||||
private readonly workspaceSessions = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
if (!pty) {
|
|
||||||
try {
|
|
||||||
pty = await import("node-pty");
|
|
||||||
this.logger.log("node-pty loaded successfully — terminal sessions available");
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(
|
|
||||||
"node-pty native module not available — terminal sessions will be disabled. " +
|
|
||||||
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new PTY session for the given workspace and socket.
|
|
||||||
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
|
||||||
*
|
|
||||||
* @throws Error if workspace session limit is exceeded or node-pty is unavailable
|
|
||||||
*/
|
|
||||||
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
|
||||||
if (!pty) {
|
|
||||||
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
|
|
||||||
}
|
|
||||||
const { workspaceId, name, cwd, socketId } = options;
|
|
||||||
const cols = options.cols ?? DEFAULT_COLS;
|
|
||||||
const rows = options.rows ?? DEFAULT_ROWS;
|
|
||||||
|
|
||||||
// Enforce per-workspace session limit
|
|
||||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set<string>();
|
|
||||||
if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) {
|
|
||||||
throw new Error(
|
|
||||||
`Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
|
||||||
const shell = process.env.SHELL ?? "/bin/bash";
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shell, [], {
|
|
||||||
name: "xterm-256color",
|
|
||||||
cols,
|
|
||||||
rows,
|
|
||||||
cwd: cwd ?? process.cwd(),
|
|
||||||
env: process.env as Record<string, string>,
|
|
||||||
});
|
|
||||||
|
|
||||||
const session: TerminalSession = {
|
|
||||||
sessionId,
|
|
||||||
workspaceId,
|
|
||||||
pty: ptyProcess,
|
|
||||||
...(name !== undefined ? { name } : {}),
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
|
||||||
|
|
||||||
// Track by workspace
|
|
||||||
if (!this.workspaceSessions.has(workspaceId)) {
|
|
||||||
this.workspaceSessions.set(workspaceId, new Set());
|
|
||||||
}
|
|
||||||
const wsSet = this.workspaceSessions.get(workspaceId);
|
|
||||||
if (wsSet) {
|
|
||||||
wsSet.add(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire PTY stdout/stderr -> terminal:output
|
|
||||||
ptyProcess.onData((data: string) => {
|
|
||||||
socket.emit("terminal:output", { sessionId, data });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wire PTY exit -> terminal:exit, cleanup
|
|
||||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
||||||
this.logger.log(
|
|
||||||
`PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})`
|
|
||||||
);
|
|
||||||
socket.emit("terminal:exit", { sessionId, exitCode, signal });
|
|
||||||
this.cleanupSession(sessionId, workspaceId);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write input data to a PTY session's stdin.
|
|
||||||
*
|
|
||||||
* @throws Error if session not found
|
|
||||||
*/
|
|
||||||
writeToSession(sessionId: string, data: string): void {
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(`Terminal session ${sessionId} not found`);
|
|
||||||
}
|
|
||||||
session.pty.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize a PTY session's terminal dimensions.
|
|
||||||
*
|
|
||||||
* @throws Error if session not found
|
|
||||||
*/
|
|
||||||
resizeSession(sessionId: string, cols: number, rows: number): void {
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(`Terminal session ${sessionId} not found`);
|
|
||||||
}
|
|
||||||
session.pty.resize(cols, rows);
|
|
||||||
this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill and clean up a specific PTY session.
|
|
||||||
* Returns true if the session existed, false if it was already gone.
|
|
||||||
*/
|
|
||||||
closeSession(sessionId: string): boolean {
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
session.pty.kill();
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cleanupSession(sessionId, session.workspaceId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close all PTY sessions for a workspace (called on client disconnect).
|
|
||||||
*/
|
|
||||||
closeWorkspaceSessions(workspaceId: string): void {
|
|
||||||
const sessionIds = this.workspaceSessions.get(workspaceId);
|
|
||||||
if (!sessionIds || sessionIds.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Copy to array to avoid mutation during iteration
|
|
||||||
const ids = Array.from(sessionIds);
|
|
||||||
for (const sessionId of ids) {
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
if (session) {
|
|
||||||
try {
|
|
||||||
session.pty.kill();
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.cleanupSession(sessionId, workspaceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of active sessions for a workspace.
|
|
||||||
*/
|
|
||||||
getWorkspaceSessionCount(workspaceId: string): number {
|
|
||||||
return this.workspaceSessions.get(workspaceId)?.size ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a session belongs to a given workspace.
|
|
||||||
* Used for access control in the gateway.
|
|
||||||
*/
|
|
||||||
sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean {
|
|
||||||
const session = this.sessions.get(sessionId);
|
|
||||||
return session?.workspaceId === workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal cleanup: remove session from tracking maps.
|
|
||||||
* Does NOT kill the PTY (caller is responsible).
|
|
||||||
*/
|
|
||||||
private cleanupSession(sessionId: string, workspaceId: string): void {
|
|
||||||
this.sessions.delete(sessionId);
|
|
||||||
|
|
||||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId);
|
|
||||||
if (workspaceSessionIds) {
|
|
||||||
workspaceSessionIds.delete(sessionId);
|
|
||||||
if (workspaceSessionIds.size === 0) {
|
|
||||||
this.workspaceSessions.delete(workspaceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { UnauthorizedException } from "@nestjs/common";
|
|
||||||
import { PreferencesController } from "./preferences.controller";
|
|
||||||
import { PreferencesService } from "./preferences.service";
|
|
||||||
import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto";
|
|
||||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
|
||||||
|
|
||||||
describe("PreferencesController", () => {
|
|
||||||
let controller: PreferencesController;
|
|
||||||
let service: PreferencesService;
|
|
||||||
|
|
||||||
const mockPreferencesService = {
|
|
||||||
getPreferences: vi.fn(),
|
|
||||||
updatePreferences: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockUserId = "user-uuid-123";
|
|
||||||
|
|
||||||
const mockPreferencesResponse: PreferencesResponseDto = {
|
|
||||||
id: "pref-uuid-456",
|
|
||||||
userId: mockUserId,
|
|
||||||
theme: "system",
|
|
||||||
locale: "en",
|
|
||||||
timezone: null,
|
|
||||||
settings: {},
|
|
||||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeRequest(userId?: string): AuthenticatedRequest {
|
|
||||||
return {
|
|
||||||
user: userId ? { id: userId } : undefined,
|
|
||||||
} as unknown as AuthenticatedRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = mockPreferencesService as unknown as PreferencesService;
|
|
||||||
controller = new PreferencesController(service);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/users/me/preferences", () => {
|
|
||||||
it("should return preferences for authenticated user", async () => {
|
|
||||||
mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse);
|
|
||||||
|
|
||||||
const result = await controller.getPreferences(makeRequest(mockUserId));
|
|
||||||
|
|
||||||
expect(result).toEqual(mockPreferencesResponse);
|
|
||||||
expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
|
||||||
await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException);
|
|
||||||
expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PUT /api/users/me/preferences", () => {
|
|
||||||
const updateDto: UpdatePreferencesDto = {
|
|
||||||
theme: "dark",
|
|
||||||
locale: "fr",
|
|
||||||
timezone: "Europe/Paris",
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should update and return preferences for authenticated user", async () => {
|
|
||||||
const updatedResponse: PreferencesResponseDto = {
|
|
||||||
...mockPreferencesResponse,
|
|
||||||
theme: "dark",
|
|
||||||
locale: "fr",
|
|
||||||
timezone: "Europe/Paris",
|
|
||||||
};
|
|
||||||
mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse);
|
|
||||||
|
|
||||||
const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId));
|
|
||||||
|
|
||||||
expect(result).toEqual(updatedResponse);
|
|
||||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
|
||||||
await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow(
|
|
||||||
UnauthorizedException
|
|
||||||
);
|
|
||||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PATCH /api/users/me/preferences", () => {
|
|
||||||
const patchDto: UpdatePreferencesDto = {
|
|
||||||
theme: "light",
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should partially update and return preferences for authenticated user", async () => {
|
|
||||||
const patchedResponse: PreferencesResponseDto = {
|
|
||||||
...mockPreferencesResponse,
|
|
||||||
theme: "light",
|
|
||||||
};
|
|
||||||
mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse);
|
|
||||||
|
|
||||||
const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId));
|
|
||||||
|
|
||||||
expect(result).toEqual(patchedResponse);
|
|
||||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
|
||||||
await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow(
|
|
||||||
UnauthorizedException
|
|
||||||
);
|
|
||||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Put,
|
Put,
|
||||||
Patch,
|
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
@@ -39,7 +38,7 @@ export class PreferencesController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/users/me/preferences
|
* PUT /api/users/me/preferences
|
||||||
* Full replace of current user's preferences
|
* Update current user's preferences
|
||||||
*/
|
*/
|
||||||
@Put()
|
@Put()
|
||||||
async updatePreferences(
|
async updatePreferences(
|
||||||
@@ -54,22 +53,4 @@ export class PreferencesController {
|
|||||||
|
|
||||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PATCH /api/users/me/preferences
|
|
||||||
* Partial update of current user's preferences
|
|
||||||
*/
|
|
||||||
@Patch()
|
|
||||||
async patchPreferences(
|
|
||||||
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
|
||||||
@Request() req: AuthenticatedRequest
|
|
||||||
) {
|
|
||||||
const userId = req.user?.id;
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new UnauthorizedException("Authentication required");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { PreferencesService } from "./preferences.service";
|
|
||||||
import type { PrismaService } from "../prisma/prisma.service";
|
|
||||||
import type { UpdatePreferencesDto } from "./dto";
|
|
||||||
|
|
||||||
describe("PreferencesService", () => {
|
|
||||||
let service: PreferencesService;
|
|
||||||
|
|
||||||
const mockPrisma = {
|
|
||||||
userPreference: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockUserId = "user-uuid-123";
|
|
||||||
|
|
||||||
const mockDbPreference = {
|
|
||||||
id: "pref-uuid-456",
|
|
||||||
userId: mockUserId,
|
|
||||||
theme: "system",
|
|
||||||
locale: "en",
|
|
||||||
timezone: null,
|
|
||||||
settings: {},
|
|
||||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new PreferencesService(mockPrisma as unknown as PrismaService);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getPreferences", () => {
|
|
||||||
it("should return existing preferences", async () => {
|
|
||||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
|
||||||
|
|
||||||
const result = await service.getPreferences(mockUserId);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
id: mockDbPreference.id,
|
|
||||||
userId: mockUserId,
|
|
||||||
theme: "system",
|
|
||||||
locale: "en",
|
|
||||||
timezone: null,
|
|
||||||
settings: {},
|
|
||||||
});
|
|
||||||
expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { userId: mockUserId },
|
|
||||||
});
|
|
||||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create default preferences when none exist", async () => {
|
|
||||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
|
||||||
mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference);
|
|
||||||
|
|
||||||
const result = await service.getPreferences(mockUserId);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
id: mockDbPreference.id,
|
|
||||||
userId: mockUserId,
|
|
||||||
theme: "system",
|
|
||||||
locale: "en",
|
|
||||||
});
|
|
||||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
userId: mockUserId,
|
|
||||||
theme: "system",
|
|
||||||
locale: "en",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("updatePreferences", () => {
|
|
||||||
it("should update existing preferences", async () => {
|
|
||||||
const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" };
|
|
||||||
const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" };
|
|
||||||
|
|
||||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
|
||||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
|
||||||
|
|
||||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ theme: "dark", locale: "fr" });
|
|
||||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
|
||||||
where: { userId: mockUserId },
|
|
||||||
data: expect.objectContaining({ theme: "dark", locale: "fr" }),
|
|
||||||
});
|
|
||||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create preferences when updating non-existent record", async () => {
|
|
||||||
const updateDto: UpdatePreferencesDto = { theme: "light" };
|
|
||||||
const createdPreference = { ...mockDbPreference, theme: "light" };
|
|
||||||
|
|
||||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
|
||||||
mockPrisma.userPreference.create.mockResolvedValue(createdPreference);
|
|
||||||
|
|
||||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ theme: "light" });
|
|
||||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({
|
|
||||||
userId: mockUserId,
|
|
||||||
theme: "light",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
expect(mockPrisma.userPreference.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle timezone update", async () => {
|
|
||||||
const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" };
|
|
||||||
const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" };
|
|
||||||
|
|
||||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
|
||||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
|
||||||
|
|
||||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
|
||||||
|
|
||||||
expect(result.timezone).toBe("America/New_York");
|
|
||||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
|
||||||
where: { userId: mockUserId },
|
|
||||||
data: expect.objectContaining({ timezone: "America/New_York" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle settings update", async () => {
|
|
||||||
const updateDto: UpdatePreferencesDto = { settings: { notifications: true } };
|
|
||||||
const updatedPreference = { ...mockDbPreference, settings: { notifications: true } };
|
|
||||||
|
|
||||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
|
||||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
|
||||||
|
|
||||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
|
||||||
|
|
||||||
expect(result.settings).toEqual({ notifications: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import { Server, Socket } from "socket.io";
|
import { Server, Socket } from "socket.io";
|
||||||
import { AuthService } from "../auth/auth.service";
|
import { AuthService } from "../auth/auth.service";
|
||||||
import { getTrustedOrigins } from "../auth/auth.config";
|
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
interface AuthenticatedSocket extends Socket {
|
||||||
@@ -78,7 +77,7 @@ interface StepOutputData {
|
|||||||
*/
|
*/
|
||||||
@WSGateway({
|
@WSGateway({
|
||||||
cors: {
|
cors: {
|
||||||
origin: getTrustedOrigins(),
|
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -168,36 +167,17 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Extract authentication token from Socket.IO handshake.
|
* @description Extract authentication token from Socket.IO handshake
|
||||||
*
|
|
||||||
* Checks sources in order:
|
|
||||||
* 1. handshake.auth.token — explicit token (e.g. from API clients)
|
|
||||||
* 2. handshake.headers.cookie — session cookie sent by browser via withCredentials
|
|
||||||
* 3. query.token — URL query parameter fallback
|
|
||||||
* 4. Authorization header — Bearer token fallback
|
|
||||||
*
|
|
||||||
* @param client - The socket client
|
* @param client - The socket client
|
||||||
* @returns The token string or undefined if not found
|
* @returns The token string or undefined if not found
|
||||||
*/
|
*/
|
||||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||||
// Check handshake.auth.token (preferred method for non-browser clients)
|
// Check handshake.auth.token (preferred method)
|
||||||
const authToken = client.handshake.auth.token as unknown;
|
const authToken = client.handshake.auth.token as unknown;
|
||||||
if (typeof authToken === "string" && authToken.length > 0) {
|
if (typeof authToken === "string" && authToken.length > 0) {
|
||||||
return authToken;
|
return authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: parse session cookie from request headers.
|
|
||||||
// Browsers send httpOnly cookies automatically when withCredentials: true is set
|
|
||||||
// on the socket.io client. BetterAuth uses one of these cookie names depending
|
|
||||||
// on whether the connection is HTTPS (Secure prefix) or HTTP (dev).
|
|
||||||
const cookieHeader = client.handshake.headers.cookie;
|
|
||||||
if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
|
|
||||||
const cookieToken = this.extractTokenFromCookieHeader(cookieHeader);
|
|
||||||
if (cookieToken) {
|
|
||||||
return cookieToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check query parameters
|
// Fallback: check query parameters
|
||||||
const queryToken = client.handshake.query.token as unknown;
|
const queryToken = client.handshake.query.token as unknown;
|
||||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||||
@@ -217,45 +197,6 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Parse the BetterAuth session token from a raw Cookie header string.
|
|
||||||
*
|
|
||||||
* BetterAuth names the session cookie differently based on the security context:
|
|
||||||
* - `__Secure-better-auth.session_token` — HTTPS with Secure flag
|
|
||||||
* - `better-auth.session_token` — HTTP (development)
|
|
||||||
* - `__Host-better-auth.session_token` — HTTPS with Host prefix
|
|
||||||
*
|
|
||||||
* @param cookieHeader - The raw Cookie header value
|
|
||||||
* @returns The session token value or undefined if no matching cookie found
|
|
||||||
*/
|
|
||||||
private extractTokenFromCookieHeader(cookieHeader: string): string | undefined {
|
|
||||||
const SESSION_COOKIE_NAMES = [
|
|
||||||
"__Secure-better-auth.session_token",
|
|
||||||
"better-auth.session_token",
|
|
||||||
"__Host-better-auth.session_token",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Parse the Cookie header into a key-value map
|
|
||||||
const cookies = Object.fromEntries(
|
|
||||||
cookieHeader.split(";").map((pair) => {
|
|
||||||
const eqIndex = pair.indexOf("=");
|
|
||||||
if (eqIndex === -1) {
|
|
||||||
return [pair.trim(), ""];
|
|
||||||
}
|
|
||||||
return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const name of SESSION_COOKIE_NAMES) {
|
|
||||||
const value = cookies[name];
|
|
||||||
if (typeof value === "string" && value.length > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Handle client disconnect by leaving the workspace room.
|
* @description Handle client disconnect by leaving the workspace room.
|
||||||
* @param client - The socket client containing workspaceId in data.
|
* @param client - The socket client containing workspaceId in data.
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { WidgetsService } from "./widgets.service";
|
import { WidgetsService } from "./widgets.service";
|
||||||
import { WidgetDataService } from "./widget-data.service";
|
import { WidgetDataService } from "./widget-data.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
|
||||||
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||||
import type { RequestWithWorkspace } from "../common/types/user.types";
|
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for widget definition and data endpoints
|
* Controller for widget definition and data endpoints
|
||||||
* All endpoints require authentication; data endpoints also require workspace context
|
* All endpoints require authentication
|
||||||
*/
|
*/
|
||||||
@Controller("widgets")
|
@Controller("widgets")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -43,9 +51,12 @@ export class WidgetsController {
|
|||||||
* Get stat card widget data
|
* Get stat card widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/stat-card")
|
@Post("data/stat-card")
|
||||||
@UseGuards(WorkspaceGuard)
|
async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
|
||||||
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||||
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace ID required");
|
||||||
|
}
|
||||||
|
return this.widgetDataService.getStatCardData(workspaceId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,9 +64,12 @@ export class WidgetsController {
|
|||||||
* Get chart widget data
|
* Get chart widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/chart")
|
@Post("data/chart")
|
||||||
@UseGuards(WorkspaceGuard)
|
async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
|
||||||
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||||
return this.widgetDataService.getChartData(req.workspace.id, query);
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace ID required");
|
||||||
|
}
|
||||||
|
return this.widgetDataService.getChartData(workspaceId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,9 +77,12 @@ export class WidgetsController {
|
|||||||
* Get list widget data
|
* Get list widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/list")
|
@Post("data/list")
|
||||||
@UseGuards(WorkspaceGuard)
|
async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
|
||||||
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||||
return this.widgetDataService.getListData(req.workspace.id, query);
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace ID required");
|
||||||
|
}
|
||||||
|
return this.widgetDataService.getListData(workspaceId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,12 +90,15 @@ export class WidgetsController {
|
|||||||
* Get calendar preview widget data
|
* Get calendar preview widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/calendar-preview")
|
@Post("data/calendar-preview")
|
||||||
@UseGuards(WorkspaceGuard)
|
|
||||||
async getCalendarPreviewData(
|
async getCalendarPreviewData(
|
||||||
@Request() req: RequestWithWorkspace,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Body() query: CalendarPreviewQueryDto
|
@Body() query: CalendarPreviewQueryDto
|
||||||
) {
|
) {
|
||||||
return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
|
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace ID required");
|
||||||
|
}
|
||||||
|
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,9 +106,12 @@ export class WidgetsController {
|
|||||||
* Get active projects widget data
|
* Get active projects widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/active-projects")
|
@Post("data/active-projects")
|
||||||
@UseGuards(WorkspaceGuard)
|
async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
|
||||||
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||||
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace ID required");
|
||||||
|
}
|
||||||
|
return this.widgetDataService.getActiveProjectsData(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,8 +119,11 @@ export class WidgetsController {
|
|||||||
* Get agent chains widget data (active agent sessions)
|
* Get agent chains widget data (active agent sessions)
|
||||||
*/
|
*/
|
||||||
@Post("data/agent-chains")
|
@Post("data/agent-chains")
|
||||||
@UseGuards(WorkspaceGuard)
|
async getAgentChainsData(@Request() req: AuthenticatedRequest) {
|
||||||
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||||
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace ID required");
|
||||||
|
}
|
||||||
|
return this.widgetDataService.getAgentChainsData(workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/orchestrator",
|
"name": "@mosaic/orchestrator",
|
||||||
"version": "0.0.20",
|
"version": "0.0.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
|
|||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/web",
|
"name": "@mosaic/web",
|
||||||
"version": "0.0.20",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -18,30 +18,15 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^9.0.0",
|
"@dnd-kit/sortable": "^9.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
|
||||||
"@mosaic/shared": "workspace:*",
|
"@mosaic/shared": "workspace:*",
|
||||||
"@mosaic/ui": "workspace:*",
|
"@mosaic/ui": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.20.0",
|
|
||||||
"@tiptap/extension-link": "^3.20.0",
|
|
||||||
"@tiptap/extension-placeholder": "^3.20.0",
|
|
||||||
"@tiptap/extension-table": "^3.20.0",
|
|
||||||
"@tiptap/extension-table-cell": "^3.20.0",
|
|
||||||
"@tiptap/extension-table-header": "^3.20.0",
|
|
||||||
"@tiptap/extension-table-row": "^3.20.0",
|
|
||||||
"@tiptap/pm": "^3.20.0",
|
|
||||||
"@tiptap/react": "^3.20.0",
|
|
||||||
"@tiptap/starter-kit": "^3.20.0",
|
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
|
||||||
"@xterm/xterm": "^6.0.0",
|
|
||||||
"@xyflow/react": "^12.5.3",
|
"@xyflow/react": "^12.5.3",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"elkjs": "^0.9.3",
|
"elkjs": "^0.9.3",
|
||||||
"lowlight": "^3.3.0",
|
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.4.1",
|
"mermaid": "^11.4.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
@@ -49,8 +34,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-grid-layout": "^2.2.2",
|
"react-grid-layout": "^2.2.2",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3"
|
||||||
"tiptap-markdown": "^0.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mosaic/config": "workspace:*",
|
"@mosaic/config": "workspace:*",
|
||||||
@@ -63,10 +47,7 @@
|
|||||||
"@types/react-grid-layout": "^2.1.0",
|
"@types/react-grid-layout": "^2.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.24",
|
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^3.4.19",
|
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vitest": "^3.0.8"
|
"vitest": "^3.0.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -127,8 +127,8 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Command Center");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack");
|
||||||
expect(screen.getByText(/Sign in to your orchestration platform/i)).toBeInTheDocument();
|
expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has proper layout styling", async (): Promise<void> => {
|
it("has proper layout styling", async (): Promise<void> => {
|
||||||
@@ -186,7 +186,7 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText(/or continue with/i)).toBeInTheDocument();
|
expect(screen.getByText(/or continue with email/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -200,11 +200,7 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The divider element should not appear (no credentials provider)
|
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
|
||||||
const dividerTexts = screen.queryAllByText(/or continue with/i);
|
|
||||||
// OAuthButton text contains "Continue with" so filter for the divider specifically
|
|
||||||
const dividerOnly = dividerTexts.filter((el) => el.textContent === "or continue with");
|
|
||||||
expect(dividerOnly).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
|
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
|
||||||
@@ -219,6 +215,7 @@ describe("LoginPage", (): void => {
|
|||||||
// Should NOT silently fall back to email form
|
// Should NOT silently fall back to email form
|
||||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
// Should show the error banner with helpful message
|
// Should show the error banner with helpful message
|
||||||
expect(
|
expect(
|
||||||
@@ -456,7 +453,7 @@ describe("LoginPage", (): void => {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
describe("responsive layout", (): void => {
|
describe("responsive layout", (): void => {
|
||||||
it("applies AuthShell layout classes to main element", async (): Promise<void> => {
|
it("applies mobile-first padding to main element", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
const { container } = render(<LoginPage />);
|
const { container } = render(<LoginPage />);
|
||||||
@@ -466,7 +463,8 @@ describe("LoginPage", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const main = container.querySelector("main");
|
const main = container.querySelector("main");
|
||||||
expect(main).toHaveClass("min-h-screen", "items-center", "justify-center");
|
|
||||||
|
expect(main).toHaveClass("p-4", "sm:p-8");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies responsive text size to heading", async (): Promise<void> => {
|
it("applies responsive text size to heading", async (): Promise<void> => {
|
||||||
@@ -479,10 +477,10 @@ describe("LoginPage", (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const heading = screen.getByRole("heading", { level: 1 });
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
expect(heading).toHaveClass("text-xl", "sm:text-2xl");
|
expect(heading).toHaveClass("text-2xl", "sm:text-4xl");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AuthCard applies card styling with padding", async (): Promise<void> => {
|
it("applies responsive padding to card container", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
const { container } = render(<LoginPage />);
|
const { container } = render(<LoginPage />);
|
||||||
@@ -491,12 +489,12 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// AuthCard uses rounded-b-2xl and p-6 sm:p-10
|
const card = container.querySelector(".bg-white");
|
||||||
const card = container.querySelector(".rounded-b-2xl");
|
|
||||||
expect(card).toHaveClass("p-6", "sm:p-10");
|
expect(card).toHaveClass("p-4", "sm:p-8");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AuthShell constrains card width", async (): Promise<void> => {
|
it("card container has full width with max-width constraint", async (): Promise<void> => {
|
||||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||||
|
|
||||||
const { container } = render(<LoginPage />);
|
const { container } = render(<LoginPage />);
|
||||||
@@ -505,9 +503,9 @@ describe("LoginPage", (): void => {
|
|||||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// AuthShell wraps children in max-w-[27rem]
|
const wrapper = container.querySelector(".max-w-md");
|
||||||
const wrapper = container.querySelector(".max-w-\\[27rem\\]");
|
|
||||||
expect(wrapper).toHaveClass("w-full");
|
expect(wrapper).toHaveClass("w-full", "max-w-md");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { ReactElement } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
||||||
import { AuthShell, AuthCard, AuthBrand, AuthStatusPill } from "@mosaic/ui";
|
|
||||||
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
|
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||||
import { signIn } from "@/lib/auth-client";
|
import { signIn } from "@/lib/auth-client";
|
||||||
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
||||||
@@ -20,21 +19,23 @@ export default function LoginPage(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<AuthShell>
|
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||||
<AuthCard>
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="text-center">
|
||||||
<AuthBrand />
|
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center py-8"
|
className="flex items-center justify-center py-8"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading authentication options"
|
aria-label="Loading authentication options"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
||||||
<span className="sr-only">Loading authentication options</span>
|
<span className="sr-only">Loading authentication options</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AuthCard>
|
</div>
|
||||||
</AuthShell>
|
</main>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LoginPageContent />
|
<LoginPageContent />
|
||||||
@@ -128,31 +129,12 @@ function LoginPageContent(): ReactElement {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const callbackURL =
|
const callbackURL =
|
||||||
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
typeof window !== "undefined" ? new URL("/", window.location.origin).toString() : "/";
|
||||||
signIn
|
signIn.oauth2({ providerId, callbackURL }).catch((err: unknown) => {
|
||||||
.oauth2({ providerId, callbackURL })
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
.then((result) => {
|
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
||||||
// BetterAuth returns Data | Error union — check for error or missing redirect URL
|
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
||||||
const hasError = "error" in result && result.error;
|
setOauthLoading(null);
|
||||||
const hasUrl = "data" in result && result.data?.url;
|
});
|
||||||
if (hasError || !hasUrl) {
|
|
||||||
const errObj = hasError ? result.error : null;
|
|
||||||
const message =
|
|
||||||
errObj && typeof errObj === "object" && "message" in errObj
|
|
||||||
? String(errObj.message)
|
|
||||||
: "no redirect URL";
|
|
||||||
console.error(`[Auth] OAuth sign-in failed for ${providerId}:`, message);
|
|
||||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
|
||||||
setOauthLoading(null);
|
|
||||||
}
|
|
||||||
// If data.url exists, BetterAuth's client will redirect the browser automatically.
|
|
||||||
// No need to reset loading — the page is navigating away.
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error(`[Auth] OAuth sign-in initiation failed for ${providerId}:`, message);
|
|
||||||
setError("Unable to connect to the sign-in provider. Please try again in a moment.");
|
|
||||||
setOauthLoading(null);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCredentialsLogin = useCallback(
|
const handleCredentialsLogin = useCallback(
|
||||||
@@ -203,51 +185,47 @@ function LoginPageContent(): ReactElement {
|
|||||||
|
|
||||||
if (IS_MOCK_AUTH_MODE) {
|
if (IS_MOCK_AUTH_MODE) {
|
||||||
return (
|
return (
|
||||||
<AuthShell>
|
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||||
<AuthCard>
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="text-center">
|
||||||
<AuthBrand />
|
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||||
<div className="text-center">
|
<p className="text-base sm:text-lg text-gray-600">
|
||||||
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
Local mock auth mode is active. Real sign-in is bypassed for frontend development.
|
||||||
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
</p>
|
||||||
Local mock auth mode is active
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md space-y-4">
|
||||||
<div className="mt-6 space-y-4">
|
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||||
<AuthStatusPill label="Mock mode" tone="warning" className="w-full justify-center" />
|
Mock auth mode is local-only and blocked outside development.
|
||||||
|
</div>
|
||||||
{error && <AuthErrorBanner message={error} />}
|
{error && <AuthErrorBanner message={error} />}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleMockLogin();
|
void handleMockLogin();
|
||||||
}}
|
}}
|
||||||
className="w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
||||||
data-testid="mock-auth-login"
|
data-testid="mock-auth-login"
|
||||||
>
|
>
|
||||||
Continue with Mock Session
|
Continue with Mock Session
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AuthCard>
|
</div>
|
||||||
</AuthShell>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell>
|
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||||
<AuthCard>
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="text-center">
|
||||||
<AuthBrand />
|
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||||
<div className="text-center">
|
<p className="text-base sm:text-lg text-gray-600">
|
||||||
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
Your personal assistant platform. Organize tasks, events, and projects with a
|
||||||
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
PDA-friendly approach.
|
||||||
Sign in to your orchestration platform
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
|
||||||
{loadingConfig ? (
|
{loadingConfig ? (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center py-8"
|
className="flex items-center justify-center py-8"
|
||||||
@@ -255,7 +233,7 @@ function LoginPageContent(): ReactElement {
|
|||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading authentication options"
|
aria-label="Loading authentication options"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
||||||
<span className="sr-only">Loading authentication options</span>
|
<span className="sr-only">Loading authentication options</span>
|
||||||
</div>
|
</div>
|
||||||
) : config === null ? (
|
) : config === null ? (
|
||||||
@@ -265,36 +243,48 @@ function LoginPageContent(): ReactElement {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRetry}
|
onClick={handleRetry}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0">
|
<>
|
||||||
{urlError && (
|
{urlError && (
|
||||||
<div className="mb-4">
|
<AuthErrorBanner
|
||||||
<AuthErrorBanner
|
message={urlError}
|
||||||
message={urlError}
|
onDismiss={(): void => {
|
||||||
onDismiss={(): void => {
|
setUrlError(null);
|
||||||
setUrlError(null);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !hasCredentials && (
|
{error && !hasCredentials && (
|
||||||
<div className="mb-4">
|
<AuthErrorBanner
|
||||||
<AuthErrorBanner
|
message={error}
|
||||||
message={error}
|
onDismiss={(): void => {
|
||||||
onDismiss={(): void => {
|
setError(null);
|
||||||
setError(null);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasOAuth &&
|
||||||
|
oauthProviders.map((provider) => (
|
||||||
|
<OAuthButton
|
||||||
|
key={provider.id}
|
||||||
|
providerName={provider.name}
|
||||||
|
providerId={provider.id}
|
||||||
|
onClick={(): void => {
|
||||||
|
handleOAuthLogin(provider.id);
|
||||||
|
}}
|
||||||
|
isLoading={oauthLoading === provider.id}
|
||||||
|
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasOAuth && hasCredentials && <AuthDivider />}
|
||||||
|
|
||||||
{hasCredentials && (
|
{hasCredentials && (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
onSubmit={handleCredentialsLogin}
|
onSubmit={handleCredentialsLogin}
|
||||||
@@ -302,33 +292,10 @@ function LoginPageContent(): ReactElement {
|
|||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{hasOAuth && hasCredentials && <AuthDivider />}
|
|
||||||
|
|
||||||
{hasOAuth && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{oauthProviders.map((provider) => (
|
|
||||||
<OAuthButton
|
|
||||||
key={provider.id}
|
|
||||||
providerName={provider.name}
|
|
||||||
providerId={provider.id}
|
|
||||||
onClick={(): void => {
|
|
||||||
handleOAuthLogin(provider.id);
|
|
||||||
}}
|
|
||||||
isLoading={oauthLoading === provider.id}
|
|
||||||
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-6 flex justify-center">
|
</main>
|
||||||
<AuthStatusPill label="Mosaic v0.0.20" tone="neutral" />
|
|
||||||
</div>
|
|
||||||
</AuthCard>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import type { Event } from "@mosaic/shared";
|
|
||||||
import CalendarPage from "./page";
|
import CalendarPage from "./page";
|
||||||
|
|
||||||
// Mock the Calendar component
|
// Mock the Calendar component
|
||||||
@@ -16,94 +15,15 @@ vi.mock("@/components/calendar/Calendar", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock MosaicSpinner
|
|
||||||
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
|
||||||
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
|
||||||
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock useWorkspaceId
|
|
||||||
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
|
||||||
vi.mock("@/lib/hooks", () => ({
|
|
||||||
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fetchEvents
|
|
||||||
const mockFetchEvents = vi.fn<() => Promise<Event[]>>();
|
|
||||||
vi.mock("@/lib/api/events", () => ({
|
|
||||||
fetchEvents: (...args: unknown[]): Promise<Event[]> => mockFetchEvents(...(args as [])),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fakeEvents: Event[] = [
|
|
||||||
{
|
|
||||||
id: "event-1",
|
|
||||||
title: "Team standup",
|
|
||||||
description: "Daily standup meeting",
|
|
||||||
startTime: new Date("2026-02-20T09:00:00Z"),
|
|
||||||
endTime: new Date("2026-02-20T09:30:00Z"),
|
|
||||||
allDay: false,
|
|
||||||
location: null,
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
projectId: null,
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "event-2",
|
|
||||||
title: "Sprint planning",
|
|
||||||
description: "Bi-weekly sprint planning",
|
|
||||||
startTime: new Date("2026-02-21T14:00:00Z"),
|
|
||||||
endTime: new Date("2026-02-21T15:00:00Z"),
|
|
||||||
allDay: false,
|
|
||||||
location: null,
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
projectId: null,
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "event-3",
|
|
||||||
title: "All-day workshop",
|
|
||||||
description: null,
|
|
||||||
startTime: new Date("2026-02-22T00:00:00Z"),
|
|
||||||
endTime: null,
|
|
||||||
allDay: true,
|
|
||||||
location: "Conference Room A",
|
|
||||||
recurrence: null,
|
|
||||||
creatorId: "user-1",
|
|
||||||
projectId: null,
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
metadata: {},
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("CalendarPage", (): void => {
|
describe("CalendarPage", (): void => {
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockUseWorkspaceId.mockReturnValue("ws-1");
|
|
||||||
mockFetchEvents.mockResolvedValue(fakeEvents);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the page title", (): void => {
|
it("should render the page title", (): void => {
|
||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Calendar");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading state initially", (): void => {
|
it("should show loading state initially", (): void => {
|
||||||
// Never resolve so we stay in loading state
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
mockFetchEvents.mockReturnValue(new Promise<Event[]>(() => {}));
|
|
||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
|
expect(screen.getByTestId("calendar")).toHaveTextContent("Loading");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the Calendar with events after loading", async (): Promise<void> => {
|
it("should render the Calendar with events after loading", async (): Promise<void> => {
|
||||||
@@ -123,31 +43,4 @@ describe("CalendarPage", (): void => {
|
|||||||
render(<CalendarPage />);
|
render(<CalendarPage />);
|
||||||
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
|
expect(screen.getByText("View your schedule at a glance")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show empty state when no events exist", async (): Promise<void> => {
|
|
||||||
mockFetchEvents.mockResolvedValue([]);
|
|
||||||
render(<CalendarPage />);
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("No events scheduled")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error state on API failure", async (): Promise<void> => {
|
|
||||||
mockFetchEvents.mockRejectedValue(new Error("Network error"));
|
|
||||||
render(<CalendarPage />);
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
|
|
||||||
mockUseWorkspaceId.mockReturnValue(null);
|
|
||||||
render(<CalendarPage />);
|
|
||||||
|
|
||||||
// Wait a tick to ensure useEffect ran
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(mockFetchEvents).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,161 +3,57 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Calendar } from "@/components/calendar/Calendar";
|
import { Calendar } from "@/components/calendar/Calendar";
|
||||||
import { fetchEvents } from "@/lib/api/events";
|
import { mockEvents } from "@/lib/api/events";
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
import type { Event } from "@mosaic/shared";
|
import type { Event } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function CalendarPage(): ReactElement {
|
export default function CalendarPage(): ReactElement {
|
||||||
const workspaceId = useWorkspaceId();
|
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsId = workspaceId;
|
|
||||||
let cancelled = false;
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
async function loadEvents(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const data = await fetchEvents(wsId);
|
|
||||||
if (!cancelled) {
|
|
||||||
setEvents(data);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Calendar] Failed to fetch events:", err);
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadEvents();
|
void loadEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (): void => {
|
async function loadEvents(): Promise<void> {
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
function handleRetry(): void {
|
|
||||||
if (!workspaceId) return;
|
|
||||||
|
|
||||||
const wsId = workspaceId;
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
fetchEvents(wsId)
|
try {
|
||||||
.then((data) => {
|
// TODO: Replace with real API call when backend is ready
|
||||||
setEvents(data);
|
// const data = await fetchEvents();
|
||||||
})
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
.catch((err: unknown) => {
|
setEvents(mockEvents);
|
||||||
console.error("[Calendar] Retry failed:", err);
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "We had trouble loading your calendar. Please try again when you're ready."
|
: "We had trouble loading your calendar. Please try again when you're ready."
|
||||||
);
|
);
|
||||||
})
|
} finally {
|
||||||
.finally(() => {
|
setIsLoading(false);
|
||||||
setIsLoading(false);
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
|
||||||
Calendar
|
|
||||||
</h1>
|
|
||||||
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
|
||||||
View your schedule at a glance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center py-16">
|
|
||||||
<MosaicSpinner label="Loading calendar..." />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
|
||||||
Calendar
|
|
||||||
</h1>
|
|
||||||
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
|
||||||
View your schedule at a glance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-6 text-center"
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--danger)" }}>{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="mt-4 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
background: "var(--accent)",
|
|
||||||
color: "var(--surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
||||||
Calendar
|
<p className="text-gray-600 mt-2">View your schedule at a glance</p>
|
||||||
</h1>
|
|
||||||
<p style={{ color: "var(--text-muted)" }} className="mt-2">
|
|
||||||
View your schedule at a glance
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{events.length === 0 ? (
|
{error !== null ? (
|
||||||
<div
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||||
className="rounded-lg p-8 text-center"
|
<p className="text-amber-800">{error}</p>
|
||||||
style={{
|
<button
|
||||||
background: "var(--surface)",
|
onClick={() => void loadEvents()}
|
||||||
border: "1px solid var(--border)",
|
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||||
}}
|
>
|
||||||
>
|
Try again
|
||||||
<p className="text-lg" style={{ color: "var(--text-muted)" }}>
|
</button>
|
||||||
No events scheduled
|
|
||||||
</p>
|
|
||||||
<p className="text-sm mt-2" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Your calendar is clear
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Calendar events={events} isLoading={false} />
|
<Calendar events={events} isLoading={isLoading} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,765 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
|
||||||
import type {
|
|
||||||
DropResult,
|
|
||||||
DroppableProvided,
|
|
||||||
DraggableProvided,
|
|
||||||
DraggableStateSnapshot,
|
|
||||||
} from "@hello-pangea/dnd";
|
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
||||||
import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks";
|
|
||||||
import { fetchProjects, type Project } from "@/lib/api/projects";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Column configuration
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface ColumnConfig {
|
|
||||||
status: TaskStatus;
|
|
||||||
label: string;
|
|
||||||
accent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMNS: ColumnConfig[] = [
|
|
||||||
{ status: TaskStatus.NOT_STARTED, label: "To Do", accent: "var(--ms-blue-400)" },
|
|
||||||
{ status: TaskStatus.IN_PROGRESS, label: "In Progress", accent: "var(--ms-amber-400)" },
|
|
||||||
{ status: TaskStatus.PAUSED, label: "Paused", accent: "var(--ms-purple-400)" },
|
|
||||||
{ status: TaskStatus.COMPLETED, label: "Done", accent: "var(--ms-teal-400)" },
|
|
||||||
{ status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PRIORITY_OPTIONS: { value: string; label: string }[] = [
|
|
||||||
{ value: "", label: "All Priorities" },
|
|
||||||
{ value: TaskPriority.HIGH, label: "High" },
|
|
||||||
{ value: TaskPriority.MEDIUM, label: "Medium" },
|
|
||||||
{ value: TaskPriority.LOW, label: "Low" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Filter select shared styles
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
const selectStyle: React.CSSProperties = {
|
|
||||||
padding: "6px 10px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
outline: "none",
|
|
||||||
minWidth: 130,
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
...selectStyle,
|
|
||||||
minWidth: 180,
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Priority badge helper
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface PriorityStyle {
|
|
||||||
label: string;
|
|
||||||
bg: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPriorityStyle(priority: TaskPriority): PriorityStyle {
|
|
||||||
switch (priority) {
|
|
||||||
case TaskPriority.HIGH:
|
|
||||||
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
|
|
||||||
case TaskPriority.MEDIUM:
|
|
||||||
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
|
||||||
case TaskPriority.LOW:
|
|
||||||
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
default:
|
|
||||||
return { label: String(priority), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Task Card
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface TaskCardProps {
|
|
||||||
task: Task;
|
|
||||||
provided: DraggableProvided;
|
|
||||||
snapshot: DraggableStateSnapshot;
|
|
||||||
columnAccent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
const priorityStyle = getPriorityStyle(task.priority);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: `1px solid ${hovered || snapshot.isDragging ? columnAccent : "var(--border)"}`,
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
padding: 12,
|
|
||||||
marginBottom: 8,
|
|
||||||
cursor: "grab",
|
|
||||||
transition: "border-color 0.15s, box-shadow 0.15s",
|
|
||||||
boxShadow: snapshot.isDragging ? "var(--shadow-lg)" : "none",
|
|
||||||
...provided.draggableProps.style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
marginBottom: 6,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priority badge */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
padding: "1px 8px",
|
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
background: priorityStyle.bg,
|
|
||||||
color: priorityStyle.color,
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{priorityStyle.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{task.description && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
margin: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
display: "-webkit-box",
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: "vertical",
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Kanban Column
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
|
||||||
config: ColumnConfig;
|
|
||||||
tasks: Task[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minWidth: 280,
|
|
||||||
maxWidth: 340,
|
|
||||||
flex: "1 0 280px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Column header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderTop: `3px solid ${config.accent}`,
|
|
||||||
padding: "12px 16px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
minWidth: 22,
|
|
||||||
height: 22,
|
|
||||||
padding: "0 6px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: `color-mix(in srgb, ${config.accent} 15%, transparent)`,
|
|
||||||
color: config.accent,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tasks.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Droppable area */}
|
|
||||||
<Droppable droppableId={config.status}>
|
|
||||||
{(provided: DroppableProvided) => (
|
|
||||||
<div
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.droppableProps}
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px 12px",
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 80,
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tasks.map((task, index) => (
|
|
||||||
<Draggable key={task.id} draggableId={task.id} index={index}>
|
|
||||||
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
|
||||||
<TaskCard
|
|
||||||
task={task}
|
|
||||||
provided={dragProvided}
|
|
||||||
snapshot={dragSnapshot}
|
|
||||||
columnAccent={config.accent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Filter Bar
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface FilterBarProps {
|
|
||||||
projects: Project[];
|
|
||||||
projectId: string;
|
|
||||||
priority: string;
|
|
||||||
search: string;
|
|
||||||
myTasks: boolean;
|
|
||||||
onProjectChange: (value: string) => void;
|
|
||||||
onPriorityChange: (value: string) => void;
|
|
||||||
onSearchChange: (value: string) => void;
|
|
||||||
onMyTasksToggle: () => void;
|
|
||||||
onClear: () => void;
|
|
||||||
hasActiveFilters: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterBar({
|
|
||||||
projects,
|
|
||||||
projectId,
|
|
||||||
priority,
|
|
||||||
search,
|
|
||||||
myTasks,
|
|
||||||
onProjectChange,
|
|
||||||
onPriorityChange,
|
|
||||||
onSearchChange,
|
|
||||||
onMyTasksToggle,
|
|
||||||
onClear,
|
|
||||||
hasActiveFilters,
|
|
||||||
}: FilterBarProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 8,
|
|
||||||
padding: "10px 14px",
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Search */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search tasks..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e): void => {
|
|
||||||
onSearchChange(e.target.value);
|
|
||||||
}}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Project filter */}
|
|
||||||
<select
|
|
||||||
value={projectId}
|
|
||||||
onChange={(e): void => {
|
|
||||||
onProjectChange(e.target.value);
|
|
||||||
}}
|
|
||||||
style={selectStyle}
|
|
||||||
>
|
|
||||||
<option value="">All Projects</option>
|
|
||||||
{projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Priority filter */}
|
|
||||||
<select
|
|
||||||
value={priority}
|
|
||||||
onChange={(e): void => {
|
|
||||||
onPriorityChange(e.target.value);
|
|
||||||
}}
|
|
||||||
style={selectStyle}
|
|
||||||
>
|
|
||||||
{PRIORITY_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* My Tasks toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onMyTasksToggle}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: myTasks ? "1px solid var(--primary)" : "1px solid var(--border)",
|
|
||||||
background: myTasks ? "var(--primary)" : "transparent",
|
|
||||||
color: myTasks ? "#fff" : "var(--text-2)",
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "all 0.12s ease",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
My Tasks
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Clear filters */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClear}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Kanban Board Page
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export default function KanbanPage(): ReactElement {
|
|
||||||
const workspaceId = useWorkspaceId();
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Read filters from URL params
|
|
||||||
const filterProject = searchParams.get("project") ?? "";
|
|
||||||
const filterPriority = searchParams.get("priority") ?? "";
|
|
||||||
const filterSearch = searchParams.get("q") ?? "";
|
|
||||||
const filterMyTasks = searchParams.get("my") === "1";
|
|
||||||
|
|
||||||
const hasActiveFilters =
|
|
||||||
filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks;
|
|
||||||
|
|
||||||
/** Update a single URL param (preserving others) */
|
|
||||||
const setParam = useCallback(
|
|
||||||
(key: string, value: string) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
if (value) {
|
|
||||||
params.set(key, value);
|
|
||||||
} else {
|
|
||||||
params.delete(key);
|
|
||||||
}
|
|
||||||
router.replace(`/kanban?${params.toString()}`, { scroll: false });
|
|
||||||
},
|
|
||||||
[searchParams, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleProjectChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setParam("project", value);
|
|
||||||
},
|
|
||||||
[setParam]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePriorityChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setParam("priority", value);
|
|
||||||
},
|
|
||||||
[setParam]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSearchChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setParam("q", value);
|
|
||||||
},
|
|
||||||
[setParam]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMyTasksToggle = useCallback(() => {
|
|
||||||
setParam("my", filterMyTasks ? "" : "1");
|
|
||||||
}, [setParam, filterMyTasks]);
|
|
||||||
|
|
||||||
const handleClearFilters = useCallback(() => {
|
|
||||||
router.replace("/kanban", { scroll: false });
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
/* --- data fetching --- */
|
|
||||||
|
|
||||||
const loadTasks = useCallback(async (wsId: string | null): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const filters = wsId !== null ? { workspaceId: wsId } : {};
|
|
||||||
const data = await fetchTasks(filters);
|
|
||||||
setTasks(data);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Kanban] Failed to fetch tasks:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Something went wrong loading tasks. You could try again when ready."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!workspaceId) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ac = new AbortController();
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const filters: TaskFilters = {};
|
|
||||||
if (workspaceId) filters.workspaceId = workspaceId;
|
|
||||||
const [taskData, projectData] = await Promise.all([
|
|
||||||
fetchTasks(filters),
|
|
||||||
fetchProjects(workspaceId ?? undefined),
|
|
||||||
]);
|
|
||||||
if (ac.signal.aborted) return;
|
|
||||||
setTasks(taskData);
|
|
||||||
setProjects(projectData);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Kanban] Failed to fetch tasks:", err);
|
|
||||||
if (ac.signal.aborted) return;
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Something went wrong loading tasks. You could try again when ready."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (!ac.signal.aborted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void load();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
ac.abort();
|
|
||||||
};
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
/* --- apply client-side filters --- */
|
|
||||||
|
|
||||||
const filteredTasks = useMemo(() => {
|
|
||||||
let result = tasks;
|
|
||||||
|
|
||||||
if (filterProject) {
|
|
||||||
result = result.filter((t) => t.projectId === filterProject);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterPriority) {
|
|
||||||
result = result.filter((t) => t.priority === (filterPriority as TaskPriority));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterSearch) {
|
|
||||||
const q = filterSearch.toLowerCase();
|
|
||||||
result = result.filter(
|
|
||||||
(t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterMyTasks) {
|
|
||||||
// "My Tasks" filters to tasks assigned to the current user.
|
|
||||||
// Since we don't have the current userId readily available,
|
|
||||||
// filter by assigneeId being non-null (assigned tasks).
|
|
||||||
// A proper implementation would compare against the logged-in user's ID.
|
|
||||||
result = result.filter((t) => t.assigneeId !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]);
|
|
||||||
|
|
||||||
/* --- group tasks by status --- */
|
|
||||||
|
|
||||||
function groupByStatus(allTasks: Task[]): Record<TaskStatus, Task[]> {
|
|
||||||
const grouped: Record<TaskStatus, Task[]> = {
|
|
||||||
[TaskStatus.NOT_STARTED]: [],
|
|
||||||
[TaskStatus.IN_PROGRESS]: [],
|
|
||||||
[TaskStatus.PAUSED]: [],
|
|
||||||
[TaskStatus.COMPLETED]: [],
|
|
||||||
[TaskStatus.ARCHIVED]: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const task of allTasks) {
|
|
||||||
grouped[task.status].push(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped;
|
|
||||||
}
|
|
||||||
|
|
||||||
const grouped = groupByStatus(filteredTasks);
|
|
||||||
|
|
||||||
/* --- drag-and-drop handler --- */
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(result: DropResult) => {
|
|
||||||
const { source, destination, draggableId } = result;
|
|
||||||
|
|
||||||
// Dropped outside a droppable area
|
|
||||||
if (!destination) return;
|
|
||||||
|
|
||||||
// Dropped in same position
|
|
||||||
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newStatus = destination.droppableId as TaskStatus;
|
|
||||||
const taskId = draggableId;
|
|
||||||
|
|
||||||
// Optimistic update: move card in local state
|
|
||||||
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
|
||||||
|
|
||||||
// Persist to API
|
|
||||||
const wsId = workspaceId ?? undefined;
|
|
||||||
updateTask(taskId, { status: newStatus }, wsId).catch((err: unknown) => {
|
|
||||||
console.error("[Kanban] Failed to update task status:", err);
|
|
||||||
// Revert on failure by re-fetching
|
|
||||||
void loadTasks(workspaceId);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[workspaceId, loadTasks]
|
|
||||||
);
|
|
||||||
|
|
||||||
/* --- retry handler --- */
|
|
||||||
|
|
||||||
function handleRetry(): void {
|
|
||||||
void loadTasks(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- render --- */
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={{ padding: "32px 24px", minHeight: "100%" }}>
|
|
||||||
{/* Page header */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: "1.875rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Kanban Board
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Visualize and manage task progress across stages
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter bar */}
|
|
||||||
<FilterBar
|
|
||||||
projects={projects}
|
|
||||||
projectId={filterProject}
|
|
||||||
priority={filterPriority}
|
|
||||||
search={filterSearch}
|
|
||||||
myTasks={filterMyTasks}
|
|
||||||
onProjectChange={handleProjectChange}
|
|
||||||
onPriorityChange={handlePriorityChange}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
onMyTasksToggle={handleMyTasksToggle}
|
|
||||||
onClear={handleClearFilters}
|
|
||||||
hasActiveFilters={hasActiveFilters}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex justify-center py-16">
|
|
||||||
<MosaicSpinner label="Loading tasks..." />
|
|
||||||
</div>
|
|
||||||
) : error !== null ? (
|
|
||||||
/* Error state */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 32,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--danger)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : filteredTasks.length === 0 && tasks.length > 0 ? (
|
|
||||||
/* No results (filtered) */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 48,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
|
||||||
No tasks match your filters.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearFilters}
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
padding: "6px 14px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : tasks.length === 0 ? (
|
|
||||||
/* Empty state */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 48,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
|
||||||
No tasks yet. Create some tasks to see them here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Board */
|
|
||||||
<DragDropContext onDragEnd={handleDragEnd}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 16,
|
|
||||||
overflowX: "auto",
|
|
||||||
paddingBottom: 16,
|
|
||||||
minHeight: 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{COLUMNS.map((col) => (
|
|
||||||
<KanbanColumn key={col.status} config={col} tasks={grouped[col.status]} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DragDropContext>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,25 +2,23 @@
|
|||||||
|
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared";
|
|
||||||
import type { EntryStatus } from "@mosaic/shared";
|
import type { EntryStatus } from "@mosaic/shared";
|
||||||
import { EntryList } from "@/components/knowledge/EntryList";
|
import { EntryList } from "@/components/knowledge/EntryList";
|
||||||
import { EntryFilters } from "@/components/knowledge/EntryFilters";
|
import { EntryFilters } from "@/components/knowledge/EntryFilters";
|
||||||
import { ImportExportActions } from "@/components/knowledge";
|
import { ImportExportActions } from "@/components/knowledge";
|
||||||
import { fetchEntries, fetchTags } from "@/lib/api/knowledge";
|
import { mockEntries, mockTags } from "@/lib/api/knowledge";
|
||||||
import type { EntriesResponse } from "@/lib/api/knowledge";
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
export default function KnowledgePage(): ReactElement {
|
export default function KnowledgePage(): ReactElement {
|
||||||
// Data state
|
// TODO: Replace with real API call when backend is ready
|
||||||
const [entries, setEntries] = useState<KnowledgeEntryWithTags[]>([]);
|
// const { data: entries, isLoading } = useQuery({
|
||||||
const [tags, setTags] = useState<KnowledgeTag[]>([]);
|
// queryKey: ["knowledge-entries"],
|
||||||
const [totalEntries, setTotalEntries] = useState(0);
|
// queryFn: fetchEntries,
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
// });
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
const [isLoading] = useState(false);
|
||||||
|
|
||||||
// Filter and sort state
|
// Filter and sort state
|
||||||
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
|
const [selectedStatus, setSelectedStatus] = useState<EntryStatus | "all">("all");
|
||||||
@@ -33,65 +31,60 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 10;
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
// Load tags on mount
|
// Client-side filtering and sorting
|
||||||
useEffect(() => {
|
const filteredAndSortedEntries = useMemo(() => {
|
||||||
let cancelled = false;
|
let filtered = [...mockEntries];
|
||||||
|
|
||||||
fetchTags()
|
// Filter by status
|
||||||
.then((result) => {
|
if (selectedStatus !== "all") {
|
||||||
if (!cancelled) {
|
filtered = filtered.filter((entry) => entry.status === selectedStatus);
|
||||||
setTags(result);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("Failed to load tags:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load entries when filters/sort/page change
|
|
||||||
const loadEntries = useCallback(async (): Promise<void> => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filters: Record<string, unknown> = {
|
|
||||||
page: currentPage,
|
|
||||||
limit: itemsPerPage,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (selectedStatus !== "all") {
|
|
||||||
filters.status = selectedStatus;
|
|
||||||
}
|
|
||||||
if (selectedTag !== "all") {
|
|
||||||
filters.tag = selectedTag;
|
|
||||||
}
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
filters.search = searchQuery.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: EntriesResponse = await fetchEntries(
|
|
||||||
filters as Parameters<typeof fetchEntries>[0]
|
|
||||||
);
|
|
||||||
setEntries(response.data);
|
|
||||||
setTotalEntries(response.meta?.total ?? response.data.length);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to load entries");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [currentPage, itemsPerPage, sortBy, sortOrder, selectedStatus, selectedTag, searchQuery]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Filter by tag
|
||||||
void loadEntries();
|
if (selectedTag !== "all") {
|
||||||
}, [loadEntries]);
|
filtered = filtered.filter((entry) =>
|
||||||
|
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalEntries / itemsPerPage));
|
// Filter by search query
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.title.toLowerCase().includes(query) ||
|
||||||
|
(entry.summary?.toLowerCase().includes(query) ?? false) ||
|
||||||
|
entry.tags.some((tag: { name: string }): boolean =>
|
||||||
|
tag.name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort entries
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
if (sortBy === "title") {
|
||||||
|
comparison = a.title.localeCompare(b.title);
|
||||||
|
} else if (sortBy === "createdAt") {
|
||||||
|
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
} else {
|
||||||
|
// updatedAt
|
||||||
|
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [selectedStatus, selectedTag, searchQuery, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const totalPages = Math.ceil(filteredAndSortedEntries.length / itemsPerPage);
|
||||||
|
const paginatedEntries = filteredAndSortedEntries.slice(
|
||||||
|
(currentPage - 1) * itemsPerPage,
|
||||||
|
currentPage * itemsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
// Reset to page 1 when filters change
|
||||||
const handleFilterChange = (callback: () => void): void => {
|
const handleFilterChange = (callback: () => void): void => {
|
||||||
@@ -108,16 +101,6 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading && entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
|
||||||
<div className="flex justify-center items-center py-20">
|
|
||||||
<MosaicSpinner size={48} label="Loading knowledge base..." />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
<main className="container mx-auto px-4 py-8 max-w-5xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -142,37 +125,14 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ImportExportActions
|
<ImportExportActions
|
||||||
onImportComplete={() => {
|
onImportComplete={() => {
|
||||||
void loadEntries();
|
// TODO: Refresh the entry list when real API is connected
|
||||||
|
// For now, this would trigger a refetch of the entries
|
||||||
|
window.location.reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="mb-6 p-4 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--danger)",
|
|
||||||
background: "rgba(229,72,77,0.08)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-sm" style={{ color: "var(--danger)" }}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void loadEntries();
|
|
||||||
}}
|
|
||||||
className="mt-2 text-sm font-medium underline"
|
|
||||||
style={{ color: "var(--danger)" }}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<EntryFilters
|
<EntryFilters
|
||||||
selectedStatus={selectedStatus}
|
selectedStatus={selectedStatus}
|
||||||
@@ -180,7 +140,7 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
tags={tags}
|
tags={mockTags}
|
||||||
onStatusChange={(status) => {
|
onStatusChange={(status) => {
|
||||||
handleFilterChange(() => {
|
handleFilterChange(() => {
|
||||||
setSelectedStatus(status);
|
setSelectedStatus(status);
|
||||||
@@ -201,7 +161,7 @@ export default function KnowledgePage(): ReactElement {
|
|||||||
|
|
||||||
{/* Entry list */}
|
{/* Entry list */}
|
||||||
<EntryList
|
<EntryList
|
||||||
entries={entries}
|
entries={paginatedEntries}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
|
|||||||
@@ -4,79 +4,10 @@ import { useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||||
import { AppHeader } from "@/components/layout/AppHeader";
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
import { AppSidebar } from "@/components/layout/AppSidebar";
|
|
||||||
import { SidebarProvider, useSidebar } from "@/components/layout/SidebarContext";
|
|
||||||
import { ChatOverlay } from "@/components/chat";
|
import { ChatOverlay } from "@/components/chat";
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constants
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const SIDEBAR_EXPANDED_WIDTH = "240px";
|
|
||||||
const SIDEBAR_COLLAPSED_WIDTH = "60px";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Inner shell — must be a child of SidebarProvider to use useSidebar
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface AppShellProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppShell({ children }: AppShellProps): React.JSX.Element {
|
|
||||||
const { collapsed, isMobile } = useSidebar();
|
|
||||||
|
|
||||||
// On tablet (md–lg), hide sidebar from the grid when the sidebar is collapsed.
|
|
||||||
// On mobile, the sidebar is fixed-position so the grid is always single-column.
|
|
||||||
const sidebarHidden = !isMobile && collapsed;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="app-shell"
|
|
||||||
data-sidebar-hidden={sidebarHidden ? "true" : undefined}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
|
|
||||||
transition: "grid-template-columns 0.2s var(--ease, ease)",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */}
|
|
||||||
<AppHeader />
|
|
||||||
|
|
||||||
{/* Sidebar — left column, row 2, via .app-sidebar CSS class */}
|
|
||||||
<AppSidebar />
|
|
||||||
|
|
||||||
{/* Main content — right column, row 2, via .app-main CSS class */}
|
|
||||||
<main className="app-main" id="main-content">
|
|
||||||
{IS_MOCK_AUTH_MODE && (
|
|
||||||
<div
|
|
||||||
className="border-b px-4 py-2 text-xs font-medium flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--ms-amber-500)",
|
|
||||||
background: "rgba(245, 158, 11, 0.08)",
|
|
||||||
color: "var(--ms-amber-400)",
|
|
||||||
}}
|
|
||||||
data-testid="mock-auth-banner"
|
|
||||||
>
|
|
||||||
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Authenticated layout — handles auth guard + provides sidebar context
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export default function AuthenticatedLayout({
|
export default function AuthenticatedLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -92,7 +23,11 @@ export default function AuthenticatedLayout({
|
|||||||
}, [isAuthenticated, isLoading, router]);
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <MosaicSpinner size={48} fullPage />;
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -100,8 +35,20 @@ export default function AuthenticatedLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<div className="min-h-screen bg-gray-50">
|
||||||
<AppShell>{children}</AppShell>
|
<Navigation />
|
||||||
</SidebarProvider>
|
<div className="pt-16">
|
||||||
|
{IS_MOCK_AUTH_MODE && (
|
||||||
|
<div
|
||||||
|
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900"
|
||||||
|
data-testid="mock-auth-banner"
|
||||||
|
>
|
||||||
|
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,851 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
||||||
import { fetchRunnerJobs, fetchJobSteps, RunnerJobStatus } from "@/lib/api/runner-jobs";
|
|
||||||
import type { RunnerJob, JobStep } from "@/lib/api/runner-jobs";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type StatusFilter = "all" | "running" | "completed" | "failed" | "queued";
|
|
||||||
type DateRange = "24h" | "7d" | "30d" | "all";
|
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
|
||||||
{ value: "all", label: "All statuses" },
|
|
||||||
{ value: "running", label: "Running" },
|
|
||||||
{ value: "completed", label: "Completed" },
|
|
||||||
{ value: "failed", label: "Failed" },
|
|
||||||
{ value: "queued", label: "Queued" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DATE_RANGES: { value: DateRange; label: string }[] = [
|
|
||||||
{ value: "24h", label: "Last 24h" },
|
|
||||||
{ value: "7d", label: "7d" },
|
|
||||||
{ value: "30d", label: "30d" },
|
|
||||||
{ value: "all", label: "All" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STATUS_FILTER_TO_ENUM: Record<StatusFilter, RunnerJobStatus[] | undefined> = {
|
|
||||||
all: undefined,
|
|
||||||
running: [RunnerJobStatus.RUNNING],
|
|
||||||
completed: [RunnerJobStatus.COMPLETED],
|
|
||||||
failed: [RunnerJobStatus.FAILED],
|
|
||||||
queued: [RunnerJobStatus.QUEUED, RunnerJobStatus.PENDING],
|
|
||||||
};
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5_000;
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getStatusColor(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case "RUNNING":
|
|
||||||
return "var(--ms-amber-400)";
|
|
||||||
case "COMPLETED":
|
|
||||||
return "var(--ms-teal-400)";
|
|
||||||
case "FAILED":
|
|
||||||
case "CANCELLED":
|
|
||||||
return "var(--danger)";
|
|
||||||
case "QUEUED":
|
|
||||||
case "PENDING":
|
|
||||||
return "var(--ms-blue-400)";
|
|
||||||
default:
|
|
||||||
return "var(--muted)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return "\u2014";
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = Date.now();
|
|
||||||
const diffMs = now - date.getTime();
|
|
||||||
const diffSec = Math.floor(diffMs / 1_000);
|
|
||||||
const diffMin = Math.floor(diffSec / 60);
|
|
||||||
const diffHr = Math.floor(diffMin / 60);
|
|
||||||
const diffDay = Math.floor(diffHr / 24);
|
|
||||||
|
|
||||||
if (diffSec < 60) return "just now";
|
|
||||||
if (diffMin < 60) return `${String(diffMin)}m ago`;
|
|
||||||
if (diffHr < 24) return `${String(diffHr)}h ago`;
|
|
||||||
if (diffDay < 30) return `${String(diffDay)}d ago`;
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(startedAt: string | null, completedAt: string | null): string {
|
|
||||||
if (!startedAt) return "\u2014";
|
|
||||||
const start = new Date(startedAt).getTime();
|
|
||||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
||||||
const ms = end - start;
|
|
||||||
if (ms < 1_000) return `${String(ms)}ms`;
|
|
||||||
const sec = Math.floor(ms / 1_000);
|
|
||||||
if (sec < 60) return `${String(sec)}s`;
|
|
||||||
const min = Math.floor(sec / 60);
|
|
||||||
const remainSec = sec % 60;
|
|
||||||
return `${String(min)}m ${String(remainSec)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStepDuration(durationMs: number | null): string {
|
|
||||||
if (durationMs === null) return "\u2014";
|
|
||||||
if (durationMs < 1_000) return `${String(durationMs)}ms`;
|
|
||||||
const sec = Math.floor(durationMs / 1_000);
|
|
||||||
if (sec < 60) return `${String(sec)}s`;
|
|
||||||
const min = Math.floor(sec / 60);
|
|
||||||
const remainSec = sec % 60;
|
|
||||||
return `${String(min)}m ${String(remainSec)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithinDateRange(dateStr: string, range: DateRange): boolean {
|
|
||||||
if (range === "all") return true;
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = Date.now();
|
|
||||||
const hours = range === "24h" ? 24 : range === "7d" ? 168 : 720;
|
|
||||||
return now - date.getTime() < hours * 60 * 60 * 1_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Status Badge ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }): ReactElement {
|
|
||||||
const color = getStatusColor(status);
|
|
||||||
const isRunning = status === "RUNNING";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
padding: "2px 10px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color,
|
|
||||||
background: `color-mix(in srgb, ${color} 15%, transparent)`,
|
|
||||||
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
|
||||||
textTransform: "capitalize",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRunning && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: color,
|
|
||||||
animation: "pulse 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{status.toLowerCase()}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page Component ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function LogsPage(): ReactElement {
|
|
||||||
const workspaceId = useWorkspaceId();
|
|
||||||
|
|
||||||
// Data state
|
|
||||||
const [jobs, setJobs] = useState<RunnerJob[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Expanded job and steps
|
|
||||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
|
||||||
const [jobStepsMap, setJobStepsMap] = useState<Record<string, JobStep[]>>({});
|
|
||||||
const [stepsLoading, setStepsLoading] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>("7d");
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
|
||||||
// Auto-refresh
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
// Hover state
|
|
||||||
const [hoveredRowId, setHoveredRowId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ─── Data Loading ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const loadJobs = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const statusEnums = STATUS_FILTER_TO_ENUM[statusFilter];
|
|
||||||
const filters: Parameters<typeof fetchRunnerJobs>[0] = {};
|
|
||||||
if (workspaceId) {
|
|
||||||
filters.workspaceId = workspaceId;
|
|
||||||
}
|
|
||||||
if (statusEnums) {
|
|
||||||
filters.status = statusEnums;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchRunnerJobs(filters);
|
|
||||||
setJobs(data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Logs] Failed to fetch runner jobs:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "We had trouble loading jobs. Please try again when you're ready."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [workspaceId, statusFilter]);
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
loadJobs()
|
|
||||||
.then(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [loadJobs]);
|
|
||||||
|
|
||||||
// Auto-refresh polling
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoRefresh) {
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
void loadJobs();
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
} else if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [autoRefresh, loadJobs]);
|
|
||||||
|
|
||||||
// ─── Steps Loading ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const toggleExpand = useCallback(
|
|
||||||
(jobId: string) => {
|
|
||||||
if (expandedJobId === jobId) {
|
|
||||||
setExpandedJobId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setExpandedJobId(jobId);
|
|
||||||
|
|
||||||
// Load steps if not already loaded
|
|
||||||
if (!jobStepsMap[jobId] && !stepsLoading.has(jobId)) {
|
|
||||||
setStepsLoading((prev) => new Set(prev).add(jobId));
|
|
||||||
|
|
||||||
fetchJobSteps(jobId, workspaceId ?? undefined)
|
|
||||||
.then((steps) => {
|
|
||||||
setJobStepsMap((prev) => ({ ...prev, [jobId]: steps }));
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("[Logs] Failed to fetch steps for job:", jobId, err);
|
|
||||||
setJobStepsMap((prev) => ({ ...prev, [jobId]: [] }));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setStepsLoading((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(jobId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[expandedJobId, jobStepsMap, stepsLoading, workspaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Filtering ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const filteredJobs = jobs.filter((job) => {
|
|
||||||
// Date range filter
|
|
||||||
if (!isWithinDateRange(job.createdAt, dateRange)) return false;
|
|
||||||
|
|
||||||
// Search filter
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const q = searchQuery.toLowerCase();
|
|
||||||
const matchesType = job.type.toLowerCase().includes(q);
|
|
||||||
const matchesId = job.id.toLowerCase().includes(q);
|
|
||||||
if (!matchesType && !matchesId) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Manual Refresh ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
const handleManualRefresh = (): void => {
|
|
||||||
setIsLoading(true);
|
|
||||||
void loadJobs().finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetry = (): void => {
|
|
||||||
setError(null);
|
|
||||||
handleManualRefresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Render ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
{/* Pulse animation for running status */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
@keyframes auto-refresh-spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
{/* ─── Header ─────────────────────────────────────────────── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 16,
|
|
||||||
marginBottom: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
|
||||||
Logs & Telemetry
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Runner job history and step-level detail
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
||||||
{/* Auto-refresh toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setAutoRefresh((prev) => !prev);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "8px 14px",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
border: `1px solid ${autoRefresh ? "var(--ms-teal-400)" : "var(--border)"}`,
|
|
||||||
background: autoRefresh
|
|
||||||
? "color-mix(in srgb, var(--ms-teal-400) 12%, transparent)"
|
|
||||||
: "var(--surface)",
|
|
||||||
color: autoRefresh ? "var(--ms-teal-400)" : "var(--text-muted)",
|
|
||||||
transition: "all 150ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{autoRefresh && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "var(--ms-teal-400)",
|
|
||||||
animation: "pulse 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
Auto-refresh {autoRefresh ? "on" : "off"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Manual refresh */}
|
|
||||||
<button
|
|
||||||
onClick={handleManualRefresh}
|
|
||||||
disabled={isLoading}
|
|
||||||
style={{
|
|
||||||
padding: "8px 14px",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: isLoading ? "not-allowed" : "pointer",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
opacity: isLoading ? 0.5 : 1,
|
|
||||||
transition: "all 150ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── Filter Bar ─────────────────────────────────────────── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Status filter */}
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatusFilter(e.target.value as StatusFilter);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
color: "var(--text)",
|
|
||||||
cursor: "pointer",
|
|
||||||
minWidth: 140,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Date range tabs */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{DATE_RANGES.map((range) => (
|
|
||||||
<button
|
|
||||||
key={range.value}
|
|
||||||
onClick={() => {
|
|
||||||
setDateRange(range.value);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "8px 14px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
border: "none",
|
|
||||||
borderRight: "1px solid var(--border)",
|
|
||||||
background: dateRange === range.value ? "var(--primary)" : "var(--surface)",
|
|
||||||
color: dateRange === range.value ? "#fff" : "var(--text-muted)",
|
|
||||||
transition: "all 150ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search input */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by job type..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "var(--surface)",
|
|
||||||
color: "var(--text)",
|
|
||||||
minWidth: 200,
|
|
||||||
flex: "1 1 200px",
|
|
||||||
maxWidth: 320,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── Content ────────────────────────────────────────────── */}
|
|
||||||
{isLoading && jobs.length === 0 ? (
|
|
||||||
<div className="flex justify-center py-16">
|
|
||||||
<MosaicSpinner label="Loading jobs..." />
|
|
||||||
</div>
|
|
||||||
) : error !== null ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-6 text-center"
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--danger)" }}>{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
|
||||||
style={{ background: "var(--danger)", cursor: "pointer", border: "none" }}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : filteredJobs.length === 0 ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-8 text-center"
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--text-muted)" }}>No jobs found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ─── Job Table ──────────────────────────────────────────── */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ overflowX: "auto" }}>
|
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{["Job Type", "Status", "Started", "Duration", "Steps"].map((header) => (
|
|
||||||
<th
|
|
||||||
key={header}
|
|
||||||
style={{
|
|
||||||
padding: "10px 16px",
|
|
||||||
textAlign: "left",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredJobs.map((job) => {
|
|
||||||
const isExpanded = expandedJobId === job.id;
|
|
||||||
const isHovered = hoveredRowId === job.id;
|
|
||||||
const steps = jobStepsMap[job.id];
|
|
||||||
const isStepsLoading = stepsLoading.has(job.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JobRow
|
|
||||||
key={job.id}
|
|
||||||
job={job}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isHovered={isHovered}
|
|
||||||
steps={steps}
|
|
||||||
isStepsLoading={isStepsLoading}
|
|
||||||
onToggle={() => {
|
|
||||||
toggleExpand(job.id);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHoveredRowId(job.id);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHoveredRowId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Job Row Component ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function JobRow({
|
|
||||||
job,
|
|
||||||
isExpanded,
|
|
||||||
isHovered,
|
|
||||||
steps,
|
|
||||||
isStepsLoading,
|
|
||||||
onToggle,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
}: {
|
|
||||||
job: RunnerJob;
|
|
||||||
isExpanded: boolean;
|
|
||||||
isHovered: boolean;
|
|
||||||
steps: JobStep[] | undefined;
|
|
||||||
isStepsLoading: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onMouseEnter: () => void;
|
|
||||||
onMouseLeave: () => void;
|
|
||||||
}): ReactElement {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr
|
|
||||||
onClick={onToggle}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
style={{
|
|
||||||
background: isExpanded
|
|
||||||
? "var(--surface-2)"
|
|
||||||
: isHovered
|
|
||||||
? "var(--surface-2)"
|
|
||||||
: "var(--surface)",
|
|
||||||
cursor: "pointer",
|
|
||||||
borderBottom: isExpanded ? "none" : "1px solid var(--border)",
|
|
||||||
transition: "background 100ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 16,
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
transition: "transform 150ms ease",
|
|
||||||
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</span>
|
|
||||||
{job.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "12px 16px" }}>
|
|
||||||
<StatusBadge status={job.status} />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatRelativeTime(job.startedAt ?? job.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatDuration(job.startedAt, job.completedAt)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{steps ? String(steps.length) : "\u2014"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* Expanded Steps Section */}
|
|
||||||
{isExpanded && (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={5}
|
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-mid)",
|
|
||||||
padding: "12px 16px 12px 48px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isStepsLoading ? (
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
|
|
||||||
<MosaicSpinner size={24} label="Loading steps..." />
|
|
||||||
</div>
|
|
||||||
) : !steps || steps.length === 0 ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.82rem",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
padding: "8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No steps recorded for this job
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{["#", "Name", "Phase", "Status", "Duration"].map((header) => (
|
|
||||||
<th
|
|
||||||
key={header}
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
textAlign: "left",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{header}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{steps
|
|
||||||
.sort((a, b) => a.ordinal - b.ordinal)
|
|
||||||
.map((step) => (
|
|
||||||
<StepRow key={step.id} step={step} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Job error message if failed */}
|
|
||||||
{job.error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
|
|
||||||
border: "1px solid color-mix(in srgb, var(--danger) 20%, transparent)",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{job.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step Row Component ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StepRow({ step }: { step: JobStep }): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: hovered ? "color-mix(in srgb, var(--surface) 50%, transparent)" : "transparent",
|
|
||||||
borderBottom: "1px solid color-mix(in srgb, var(--border) 50%, transparent)",
|
|
||||||
transition: "background 100ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{String(step.ordinal)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{step.name}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
textTransform: "lowercase",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{step.phase}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "6px 12px" }}>
|
|
||||||
<StatusBadge status={step.status} />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatStepDuration(step.durationMs)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import type { ReactElement } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function AuthenticatedNotFound(): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
minHeight: "60vh",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "24px",
|
|
||||||
padding: "48px 40px",
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-xl)",
|
|
||||||
boxShadow: "var(--shadow-md)",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: "420px",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Compass icon in blue-tinted icon well */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
background: "rgba(47, 128, 255, 0.1)",
|
|
||||||
color: "var(--ms-blue-400)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="28"
|
|
||||||
height="28"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polygon
|
|
||||||
points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"
|
|
||||||
fill="currentColor"
|
|
||||||
stroke="none"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 404 badge pill */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "4px 12px",
|
|
||||||
borderRadius: "9999px",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
background: "rgba(47, 128, 255, 0.15)",
|
|
||||||
color: "var(--ms-blue-400)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
404
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Heading + description */}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "1.25rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
letterSpacing: "-0.01em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Page not found
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: 0,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
This page doesn't exist or you may not have permission to view it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "12px",
|
|
||||||
marginTop: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Primary: Dashboard */}
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "9px 20px",
|
|
||||||
background: "var(--ms-blue-500)",
|
|
||||||
color: "#ffffff",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
textDecoration: "none",
|
|
||||||
transition: "opacity 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Ghost: Settings */}
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "9px 20px",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
textDecoration: "none",
|
|
||||||
transition: "all 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +1,85 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import DashboardPage from "./page";
|
import DashboardPage from "./page";
|
||||||
import * as layoutsApi from "@/lib/api/layouts";
|
|
||||||
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
|
||||||
|
|
||||||
// ResizeObserver is not available in jsdom
|
// Mock dashboard widgets
|
||||||
beforeAll((): void => {
|
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
|
||||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
RecentTasksWidget: ({
|
||||||
observe: vi.fn(),
|
tasks,
|
||||||
unobserve: vi.fn(),
|
isLoading,
|
||||||
disconnect: vi.fn(),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock WidgetGrid to avoid react-grid-layout dependency in tests
|
|
||||||
vi.mock("@/components/widgets/WidgetGrid", () => ({
|
|
||||||
WidgetGrid: ({
|
|
||||||
layout,
|
|
||||||
isEditing,
|
|
||||||
}: {
|
}: {
|
||||||
layout: WidgetPlacement[];
|
tasks: unknown[];
|
||||||
isEditing?: boolean;
|
isLoading: boolean;
|
||||||
}): React.JSX.Element => (
|
}): React.JSX.Element => (
|
||||||
<div data-testid="widget-grid" data-editing={isEditing}>
|
<div data-testid="recent-tasks">
|
||||||
{layout.map((item) => (
|
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
|
||||||
<div key={item.i} data-testid={`widget-${item.i}`}>
|
|
||||||
{item.i}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock hooks
|
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
|
||||||
vi.mock("@/lib/hooks", () => ({
|
UpcomingEventsWidget: ({
|
||||||
useWorkspaceId: (): string | null => "ws-test-123",
|
events,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
events: unknown[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div data-testid="upcoming-events">
|
||||||
|
{isLoading ? "Loading events" : `${String(events.length)} events`}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock layout API
|
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
|
||||||
vi.mock("@/lib/api/layouts");
|
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
const mockExistingLayout: UserLayout = {
|
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
|
||||||
id: "layout-1",
|
DomainOverviewWidget: ({
|
||||||
workspaceId: "ws-test-123",
|
tasks,
|
||||||
userId: "user-1",
|
isLoading,
|
||||||
name: "Default",
|
}: {
|
||||||
isDefault: true,
|
tasks: unknown[];
|
||||||
layout: [
|
isLoading: boolean;
|
||||||
{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 },
|
}): React.JSX.Element => (
|
||||||
{ i: "CalendarWidget-default", x: 4, y: 0, w: 4, h: 2 },
|
<div data-testid="domain-overview">
|
||||||
],
|
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
|
||||||
metadata: {},
|
</div>
|
||||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
),
|
||||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
}));
|
||||||
};
|
|
||||||
|
|
||||||
describe("DashboardPage", (): void => {
|
describe("DashboardPage", (): void => {
|
||||||
beforeEach((): void => {
|
it("should render the page title", (): void => {
|
||||||
vi.clearAllMocks();
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render WidgetGrid with saved layout", async (): Promise<void> => {
|
it("should show loading state initially", (): void => {
|
||||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("Loading tasks");
|
||||||
|
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("Loading events");
|
||||||
|
expect(screen.getByTestId("domain-overview")).toHaveTextContent("Loading overview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render all widgets with data after loading", async (): Promise<void> => {
|
||||||
|
render(<DashboardPage />);
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
|
||||||
});
|
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("3 events");
|
||||||
|
expect(screen.getByTestId("domain-overview")).toHaveTextContent("4 tasks overview");
|
||||||
expect(screen.getByTestId("widget-TasksWidget-default")).toBeInTheDocument();
|
expect(screen.getByTestId("quick-capture")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("widget-CalendarWidget-default")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create default layout when none exists", async (): Promise<void> => {
|
|
||||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(null);
|
|
||||||
vi.mocked(layoutsApi.createLayout).mockResolvedValue({
|
|
||||||
...mockExistingLayout,
|
|
||||||
layout: [{ i: "TasksWidget-default", x: 0, y: 0, w: 4, h: 2 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(layoutsApi.createLayout).toHaveBeenCalledWith("ws-test-123", {
|
|
||||||
name: "Default",
|
|
||||||
isDefault: true,
|
|
||||||
layout: expect.arrayContaining([
|
|
||||||
expect.objectContaining({ i: "TasksWidget-default" }),
|
|
||||||
]) as WidgetPlacement[],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading spinner initially", (): void => {
|
it("should have proper layout structure", (): void => {
|
||||||
// Never-resolving promise to test loading state
|
const { container } = render(<DashboardPage />);
|
||||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockReturnValue(
|
const main = container.querySelector("main");
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving
|
expect(main).toBeInTheDocument();
|
||||||
new Promise(() => {})
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to default layout on API error", async (): Promise<void> => {
|
it("should render the welcome subtitle", (): void => {
|
||||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockRejectedValue(new Error("Network error"));
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByTestId("widget-grid")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render Dashboard heading", async (): Promise<void> => {
|
|
||||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render Edit Layout button", async (): Promise<void> => {
|
|
||||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should toggle edit mode on button click", async (): Promise<void> => {
|
|
||||||
vi.mocked(layoutsApi.fetchDefaultLayout).mockResolvedValue(mockExistingLayout);
|
|
||||||
|
|
||||||
render(<DashboardPage />);
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Edit Layout")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
act((): void => {
|
|
||||||
screen.getByText("Edit Layout").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("widget-grid").getAttribute("data-editing")).toBe("true");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,242 +1,78 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import type { WidgetPlacement } from "@mosaic/shared";
|
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
|
||||||
import { WidgetGrid } from "@/components/widgets/WidgetGrid";
|
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
|
||||||
import { WidgetPicker } from "@/components/widgets/WidgetPicker";
|
import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget";
|
||||||
import { WidgetConfigDialog } from "@/components/widgets/WidgetConfigDialog";
|
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
|
||||||
import { DEFAULT_LAYOUT } from "@/components/widgets/defaultLayout";
|
import { mockTasks } from "@/lib/api/tasks";
|
||||||
import { fetchDefaultLayout, createLayout, updateLayout } from "@/lib/api/layouts";
|
import { mockEvents } from "@/lib/api/events";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
import type { Task, Event } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function DashboardPage(): ReactElement {
|
export default function DashboardPage(): ReactElement {
|
||||||
const workspaceId = useWorkspaceId();
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [layout, setLayout] = useState<WidgetPlacement[]>(DEFAULT_LAYOUT);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [layoutId, setLayoutId] = useState<string | null>(null);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
||||||
const [configWidgetId, setConfigWidgetId] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Debounce timer for auto-saving layout changes
|
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Load the user's default layout (or create one)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
void loadDashboardData();
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsId = workspaceId;
|
|
||||||
const ac = new AbortController();
|
|
||||||
|
|
||||||
async function loadLayout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const existing = await fetchDefaultLayout(wsId);
|
|
||||||
if (ac.signal.aborted) return;
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
setLayout(existing.layout);
|
|
||||||
setLayoutId(existing.id);
|
|
||||||
} else {
|
|
||||||
const created = await createLayout(wsId, {
|
|
||||||
name: "Default",
|
|
||||||
isDefault: true,
|
|
||||||
layout: DEFAULT_LAYOUT,
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- aborted can change during await
|
|
||||||
if (ac.signal.aborted) return;
|
|
||||||
setLayout(created.layout);
|
|
||||||
setLayoutId(created.id);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Dashboard] Failed to load layout:", err);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadLayout();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
ac.abort();
|
|
||||||
};
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
// Save layout changes with debounce
|
|
||||||
const saveLayout = useCallback(
|
|
||||||
(newLayout: WidgetPlacement[]) => {
|
|
||||||
if (!workspaceId || !layoutId) return;
|
|
||||||
|
|
||||||
if (saveTimerRef.current) {
|
|
||||||
clearTimeout(saveTimerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveTimerRef.current = setTimeout(() => {
|
|
||||||
void updateLayout(workspaceId, layoutId, { layout: newLayout }).catch((err: unknown) => {
|
|
||||||
console.error("[Dashboard] Failed to save layout:", err);
|
|
||||||
});
|
|
||||||
}, 800);
|
|
||||||
},
|
|
||||||
[workspaceId, layoutId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLayoutChange = useCallback(
|
|
||||||
(newLayout: WidgetPlacement[]) => {
|
|
||||||
setLayout(newLayout);
|
|
||||||
saveLayout(newLayout);
|
|
||||||
},
|
|
||||||
[saveLayout]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveWidget = useCallback(
|
|
||||||
(widgetId: string) => {
|
|
||||||
const updated = layout.filter((item) => item.i !== widgetId);
|
|
||||||
setLayout(updated);
|
|
||||||
saveLayout(updated);
|
|
||||||
},
|
|
||||||
[layout, saveLayout]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddWidget = useCallback(
|
|
||||||
(placement: WidgetPlacement) => {
|
|
||||||
const updated = [...layout, placement];
|
|
||||||
setLayout(updated);
|
|
||||||
saveLayout(updated);
|
|
||||||
},
|
|
||||||
[layout, saveLayout]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleResetLayout = useCallback((): void => {
|
|
||||||
setLayout(DEFAULT_LAYOUT);
|
|
||||||
saveLayout(DEFAULT_LAYOUT);
|
|
||||||
}, [saveLayout]);
|
|
||||||
|
|
||||||
const handleEditWidget = useCallback((widgetId: string): void => {
|
|
||||||
setConfigWidgetId(widgetId);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (isLoading) {
|
async function loadDashboardData(): Promise<void> {
|
||||||
return (
|
setIsLoading(true);
|
||||||
<div className="flex items-center justify-center" style={{ minHeight: 400 }}>
|
setError(null);
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div
|
try {
|
||||||
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
|
// TODO: Replace with real API calls when backend is ready
|
||||||
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
|
// const [tasksData, eventsData] = await Promise.all([fetchTasks(), fetchEvents()]);
|
||||||
/>
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
<span className="text-sm" style={{ color: "var(--muted)" }}>
|
setTasks(mockTasks);
|
||||||
Loading dashboard...
|
setEvents(mockEvents);
|
||||||
</span>
|
} catch (err) {
|
||||||
</div>
|
setError(
|
||||||
</div>
|
err instanceof Error
|
||||||
);
|
? err.message
|
||||||
|
: "We had trouble loading your dashboard. Please try again when you're ready."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<main className="container mx-auto px-4 py-8">
|
||||||
{/* Dashboard header with edit toggle */}
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<h1
|
<p className="text-gray-600 mt-2">Welcome back! Here's your overview</p>
|
||||||
style={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isEditing && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleResetLayout}
|
|
||||||
style={{
|
|
||||||
padding: "6px 14px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "all 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(): void => {
|
|
||||||
setIsPickerOpen(true);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "6px 14px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
background: "transparent",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "all 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ Add Widget
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={(): void => {
|
|
||||||
setIsEditing((prev) => !prev);
|
|
||||||
if (isEditing) setIsPickerOpen(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "6px 14px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
border: isEditing ? "1px solid var(--primary)" : "1px solid var(--border)",
|
|
||||||
background: isEditing ? "var(--primary)" : "transparent",
|
|
||||||
color: isEditing ? "#fff" : "var(--text-2)",
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "all 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isEditing ? "Done" : "Edit Layout"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Widget grid */}
|
{error !== null ? (
|
||||||
<WidgetGrid
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||||
layout={layout}
|
<p className="text-amber-800">{error}</p>
|
||||||
onLayoutChange={handleLayoutChange}
|
<button
|
||||||
{...(isEditing && { onRemoveWidget: handleRemoveWidget })}
|
onClick={() => void loadDashboardData()}
|
||||||
{...(isEditing && { onEditWidget: handleEditWidget })}
|
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||||
isEditing={isEditing}
|
>
|
||||||
/>
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Top row: Domain Overview and Quick Capture */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<DomainOverviewWidget tasks={tasks} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Widget config dialog */}
|
<RecentTasksWidget tasks={tasks} isLoading={isLoading} />
|
||||||
{configWidgetId && (
|
<UpcomingEventsWidget events={events} isLoading={isLoading} />
|
||||||
<WidgetConfigDialog
|
|
||||||
widgetId={configWidgetId}
|
<div className="lg:col-span-2">
|
||||||
open
|
<QuickCaptureWidget />
|
||||||
onClose={(): void => {
|
</div>
|
||||||
setConfigWidgetId(null);
|
</div>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
</main>
|
||||||
{/* Widget picker drawer */}
|
|
||||||
<WidgetPicker
|
|
||||||
open={isPickerOpen}
|
|
||||||
onClose={(): void => {
|
|
||||||
setIsPickerOpen(false);
|
|
||||||
}}
|
|
||||||
onAddWidget={handleAddWidget}
|
|
||||||
currentLayout={layout}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,467 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
|
||||||
import { apiGet } from "@/lib/api/client";
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UserPreferences {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
theme: string;
|
|
||||||
locale: string;
|
|
||||||
timezone: string | null;
|
|
||||||
settings: Record<string, unknown>;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Sub-components ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PreferenceRowProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreferenceRow({ label, value }: PreferenceRowProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "12px 0",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "0.9rem", color: "var(--text-2)" }}>{label}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreferencesSkeleton(): ReactElement {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "12px 0",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 80,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--surface-2)",
|
|
||||||
animation: "pulse 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 16,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--surface-2)",
|
|
||||||
animation: "pulse 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page Component ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function ProfilePage(): ReactElement {
|
|
||||||
const { user, signOut } = useAuth();
|
|
||||||
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
|
|
||||||
const [prefsLoading, setPrefsLoading] = useState(true);
|
|
||||||
const [prefsError, setPrefsError] = useState<string | null>(null);
|
|
||||||
const [signOutHovered, setSignOutHovered] = useState(false);
|
|
||||||
const [settingsHovered, setSettingsHovered] = useState(false);
|
|
||||||
|
|
||||||
const loadPreferences = useCallback(async (): Promise<void> => {
|
|
||||||
setPrefsLoading(true);
|
|
||||||
setPrefsError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await apiGet<UserPreferences>("/api/users/me/preferences");
|
|
||||||
setPreferences(data);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : "Could not load preferences";
|
|
||||||
setPrefsError(message);
|
|
||||||
} finally {
|
|
||||||
setPrefsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadPreferences();
|
|
||||||
}, [loadPreferences]);
|
|
||||||
|
|
||||||
// User initials for avatar fallback
|
|
||||||
const initials = user?.name
|
|
||||||
? user.name
|
|
||||||
.split(" ")
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
: user?.email
|
|
||||||
? (user.email[0] ?? "?").toUpperCase()
|
|
||||||
: "?";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
|
||||||
{/* ── Page Header ── */}
|
|
||||||
<div style={{ marginBottom: 32 }}>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: "1.875rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: "8px 0 0 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Your account information and preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── User Info Card ── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-xl)",
|
|
||||||
padding: 28,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 20 }}>
|
|
||||||
{/* Avatar (64px) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: user?.image
|
|
||||||
? "none"
|
|
||||||
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user?.image ? (
|
|
||||||
<img
|
|
||||||
src={user.image}
|
|
||||||
alt={user.name || user.email || "User avatar"}
|
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "1.25rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#fff",
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name, email, role, status */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "1.25rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user?.name ?? "User"}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Online indicator */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "var(--success)",
|
|
||||||
boxShadow: "0 0 6px var(--success)",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--success)",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Online
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user?.email && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: "4px 0 0 0",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user?.workspaceRole && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
marginTop: 8,
|
|
||||||
padding: "3px 10px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: "rgba(47, 128, 255, 0.1)",
|
|
||||||
color: "var(--ms-blue-400)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "capitalize",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.workspaceRole}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Preferences Section ── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-xl)",
|
|
||||||
padding: 28,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
fontSize: "1.125rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: "0 0 16px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Preferences
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{prefsLoading ? (
|
|
||||||
<PreferencesSkeleton />
|
|
||||||
) : prefsError ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px 20px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: "rgba(245, 158, 11, 0.08)",
|
|
||||||
border: "1px solid rgba(245, 158, 11, 0.2)",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 500 }}>Preferences unavailable</span>
|
|
||||||
<span style={{ color: "var(--muted)", marginLeft: 8 }}>— {prefsError}</span>
|
|
||||||
</div>
|
|
||||||
) : preferences ? (
|
|
||||||
<div>
|
|
||||||
<PreferenceRow label="Theme" value={preferences.theme} />
|
|
||||||
<PreferenceRow label="Locale" value={preferences.locale} />
|
|
||||||
<PreferenceRow label="Timezone" value={preferences.timezone ?? "Not set"} />
|
|
||||||
{Object.keys(preferences.settings).length > 0 && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text-2)",
|
|
||||||
margin: "16px 0 8px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Custom Settings
|
|
||||||
</div>
|
|
||||||
{Object.entries(preferences.settings).map(([key, value]) => (
|
|
||||||
<PreferenceRow key={key} label={key} value={String(value)} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No preferences configured yet.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Account Actions ── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-xl)",
|
|
||||||
padding: 28,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
fontSize: "1.125rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: "0 0 16px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Account
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
||||||
{/* Settings link */}
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "10px 20px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: settingsHovered ? "var(--surface-2)" : "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
textDecoration: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "background 0.15s ease, border-color 0.15s ease",
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setSettingsHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setSettingsHovered(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="8" cy="8" r="2.5" />
|
|
||||||
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
|
|
||||||
</svg>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Sign Out button */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void signOut();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "10px 20px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: signOutHovered ? "rgba(239, 68, 68, 0.1)" : "transparent",
|
|
||||||
border: "1px solid var(--danger)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "background 0.15s ease",
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setSignOutHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setSignOutHovered(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
|
|
||||||
</svg>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,809 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import type { ReactElement, SyntheticEvent } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects";
|
|
||||||
import type { Project, CreateProjectDto } from "@/lib/api/projects";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Status badge helpers
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface StatusStyle {
|
|
||||||
label: string;
|
|
||||||
bg: string;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusStyle(status: ProjectStatus): StatusStyle {
|
|
||||||
switch (status) {
|
|
||||||
case ProjectStatus.PLANNING:
|
|
||||||
return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
|
|
||||||
case ProjectStatus.ACTIVE:
|
|
||||||
return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
|
|
||||||
case ProjectStatus.PAUSED:
|
|
||||||
return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
|
|
||||||
case ProjectStatus.COMPLETED:
|
|
||||||
return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
|
|
||||||
case ProjectStatus.ARCHIVED:
|
|
||||||
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
default:
|
|
||||||
return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
ProjectCard
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface ProjectCardProps {
|
|
||||||
project: Project;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onClick: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
const status = getStatusStyle(project.status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => {
|
|
||||||
onClick(project.id);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick(project.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`,
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 20,
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "border-color 0.2s var(--ease)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 12,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header row: name + delete button */}
|
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "1rem",
|
|
||||||
margin: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete button */}
|
|
||||||
<button
|
|
||||||
aria-label={`Delete project ${project.name}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete(project.id);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: 4,
|
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
color: "var(--muted)",
|
|
||||||
transition: "color 0.15s, background 0.15s",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = "var(--danger)";
|
|
||||||
e.currentTarget.style.background = "rgba(229,72,77,0.1)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = "var(--muted)";
|
|
||||||
e.currentTarget.style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{project.description ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
margin: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
display: "-webkit-box",
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
WebkitBoxOrient: "vertical",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: "var(--muted)", fontSize: "0.85rem", margin: 0, fontStyle: "italic" }}>
|
|
||||||
No description
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer: status + timestamps */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginTop: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Status badge */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
padding: "2px 10px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: status.bg,
|
|
||||||
color: status.color,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Timestamps */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTimestamp(project.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Create Project Dialog
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface CreateDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateProjectDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSubmit,
|
|
||||||
isSubmitting,
|
|
||||||
}: CreateDialogProps): ReactElement {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function resetForm(): void {
|
|
||||||
setName("");
|
|
||||||
setDescription("");
|
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
const trimmedName = name.trim();
|
|
||||||
if (!trimmedName) {
|
|
||||||
setFormError("Project name is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload: CreateProjectDto = { name: trimmedName };
|
|
||||||
const trimmedDesc = description.trim();
|
|
||||||
if (trimmedDesc) {
|
|
||||||
payload.description = trimmedDesc;
|
|
||||||
}
|
|
||||||
await onSubmit(payload);
|
|
||||||
resetForm();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setFormError(err instanceof Error ? err.message : "Failed to create project.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) resetForm();
|
|
||||||
onOpenChange(isOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
padding: 24,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<span style={{ color: "var(--text)" }}>New Project</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<span style={{ color: "var(--muted)" }}>
|
|
||||||
Give your project a name and optional description.
|
|
||||||
</span>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
void handleSubmit(e);
|
|
||||||
}}
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="project-name"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Name <span style={{ color: "var(--danger)" }}>*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="project-name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
setName(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="e.g. Website Redesign"
|
|
||||||
maxLength={255}
|
|
||||||
autoFocus
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="project-description"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="project-description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDescription(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="A brief summary of this project..."
|
|
||||||
rows={3}
|
|
||||||
maxLength={10000}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "var(--text)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
resize: "vertical",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form error */}
|
|
||||||
{formError !== null && (
|
|
||||||
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
|
||||||
{formError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !name.trim()}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--primary)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: isSubmitting || !name.trim() ? "not-allowed" : "pointer",
|
|
||||||
opacity: isSubmitting || !name.trim() ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Creating..." : "Create Project"}
|
|
||||||
</button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Delete Confirmation Dialog
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface DeleteDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
projectName: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isDeleting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeleteConfirmDialog({
|
|
||||||
open,
|
|
||||||
projectName,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
isDeleting,
|
|
||||||
}: DeleteDialogProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) onCancel();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
padding: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<span style={{ color: "var(--text)" }}>Delete Project</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<span style={{ color: "var(--muted)" }}>
|
|
||||||
{"This will permanently delete "}
|
|
||||||
<strong style={{ color: "var(--text)" }}>{projectName}</strong>
|
|
||||||
{". This action cannot be undone."}
|
|
||||||
</span>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isDeleting}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "var(--text-2)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={isDeleting}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--danger)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: isDeleting ? "not-allowed" : "pointer",
|
|
||||||
opacity: isDeleting ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDeleting ? "Deleting..." : "Delete"}
|
|
||||||
</button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Projects Page
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export default function ProjectsPage(): ReactElement {
|
|
||||||
const router = useRouter();
|
|
||||||
const workspaceId = useWorkspaceId();
|
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Create dialog state
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
|
|
||||||
// Delete dialog state
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
|
|
||||||
const loadProjects = useCallback(async (wsId: string | null): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await fetchProjects(wsId ?? undefined);
|
|
||||||
setProjects(data);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Projects] Failed to fetch projects:", err);
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Something went wrong loading projects. You could try again when ready."
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!workspaceId) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const wsId = workspaceId;
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await fetchProjects(wsId);
|
|
||||||
if (!cancelled) {
|
|
||||||
setProjects(data);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Projects] Failed to fetch projects:", err);
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Something went wrong loading projects. You could try again when ready."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void load();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
function handleRetry(): void {
|
|
||||||
void loadProjects(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate(data: CreateProjectDto): Promise<void> {
|
|
||||||
setIsCreating(true);
|
|
||||||
try {
|
|
||||||
await createProject(data, workspaceId ?? undefined);
|
|
||||||
setCreateOpen(false);
|
|
||||||
void loadProjects(workspaceId);
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteRequest(projectId: string): void {
|
|
||||||
const target = projects.find((p) => p.id === projectId);
|
|
||||||
if (target) {
|
|
||||||
setDeleteTarget(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteConfirm(): Promise<void> {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
await deleteProject(deleteTarget.id, workspaceId ?? undefined);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
void loadProjects(workspaceId);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Projects] Failed to delete project:", err);
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to delete project.");
|
|
||||||
setDeleteTarget(null);
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCardClick(projectId: string): void {
|
|
||||||
router.push(`/projects/${projectId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: 32,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: "1.875rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Projects
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
marginTop: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Organize and track your work across different initiatives
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setCreateOpen(true);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--primary)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
New Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex justify-center py-16">
|
|
||||||
<MosaicSpinner label="Loading projects..." />
|
|
||||||
</div>
|
|
||||||
) : error !== null ? (
|
|
||||||
/* Error */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 32,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--danger)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : projects.length === 0 ? (
|
|
||||||
/* Empty */
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 48,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--muted)", margin: "0 0 16px", fontSize: "0.9rem" }}>
|
|
||||||
No projects yet. Create your first project to get started.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setCreateOpen(true);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--primary)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
Create Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Projects grid */
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<ProjectCard
|
|
||||||
key={project.id}
|
|
||||||
project={project}
|
|
||||||
onDelete={handleDeleteRequest}
|
|
||||||
onClick={handleCardClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Dialog */}
|
|
||||||
<CreateProjectDialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
onSubmit={handleCreate}
|
|
||||||
isSubmitting={isCreating}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<DeleteConfirmDialog
|
|
||||||
open={deleteTarget !== null}
|
|
||||||
projectName={deleteTarget?.name ?? ""}
|
|
||||||
onConfirm={() => {
|
|
||||||
void handleDeleteConfirm();
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setDeleteTarget(null);
|
|
||||||
}}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
|
||||||
import { getAllThemes, type ThemeDefinition } from "@/themes";
|
|
||||||
import { apiPatch } from "@/lib/api/client";
|
|
||||||
|
|
||||||
function ThemeCard({
|
|
||||||
theme,
|
|
||||||
isActive,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
theme: ThemeDefinition;
|
|
||||||
isActive: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 12,
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
|
||||||
border: isActive
|
|
||||||
? "2px solid var(--primary)"
|
|
||||||
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
|
||||||
cursor: "pointer",
|
|
||||||
textAlign: "left",
|
|
||||||
transition: "all 0.15s ease",
|
|
||||||
position: "relative",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
aria-label={`Select ${theme.name} theme`}
|
|
||||||
aria-pressed={isActive}
|
|
||||||
>
|
|
||||||
{/* Color preview swatches */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 0,
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
overflow: "hidden",
|
|
||||||
height: 48,
|
|
||||||
width: "100%",
|
|
||||||
border: "1px solid rgba(128, 128, 128, 0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{theme.colorPreview.map((color, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme info */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{theme.name}
|
|
||||||
</span>
|
|
||||||
{isActive && (
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--primary)"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<polyline points="13 4 6 12 3 9" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
padding: "2px 6px",
|
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
background: theme.isDark ? "rgba(128,128,128,0.15)" : "rgba(245,158,11,0.12)",
|
|
||||||
color: theme.isDark ? "var(--muted)" : "var(--warn)",
|
|
||||||
fontWeight: 500,
|
|
||||||
marginLeft: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{theme.isDark ? "Dark" : "Light"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: 0,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{theme.description}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemThemeCard({
|
|
||||||
isActive,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
isActive: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 12,
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
|
||||||
border: isActive
|
|
||||||
? "2px solid var(--primary)"
|
|
||||||
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
|
||||||
cursor: "pointer",
|
|
||||||
textAlign: "left",
|
|
||||||
transition: "all 0.15s ease",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
aria-label="Use system theme preference"
|
|
||||||
aria-pressed={isActive}
|
|
||||||
>
|
|
||||||
{/* Split preview (dark | light) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 0,
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
overflow: "hidden",
|
|
||||||
height: 48,
|
|
||||||
width: "100%",
|
|
||||||
border: "1px solid rgba(128, 128, 128, 0.15)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, background: "#0f141d" }} />
|
|
||||||
<div style={{ flex: 1, background: "#1b2331" }} />
|
|
||||||
<div style={{ flex: 1, background: "#f0f4fc" }} />
|
|
||||||
<div style={{ flex: 1, background: "#dde4f2" }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: "linear-gradient(135deg, #2f80ff 50%, #8b5cf6 50%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--text)" }}>System</span>
|
|
||||||
{isActive && (
|
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--primary)"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<polyline points="13 4 6 12 3 9" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
padding: "2px 6px",
|
|
||||||
borderRadius: "var(--r-sm)",
|
|
||||||
background: "rgba(47, 128, 255, 0.12)",
|
|
||||||
color: "var(--primary-l)",
|
|
||||||
fontWeight: 500,
|
|
||||||
marginLeft: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Auto
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.78rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: 0,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Follows your operating system appearance preference
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppearanceSettingsPage(): ReactElement {
|
|
||||||
const { theme: preference, setTheme: setLocalTheme } = useTheme();
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const allThemes = getAllThemes();
|
|
||||||
|
|
||||||
const handleThemeSelect = useCallback(
|
|
||||||
async (themeId: string) => {
|
|
||||||
setLocalTheme(themeId);
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await apiPatch("/api/users/me/preferences", { theme: themeId });
|
|
||||||
} catch {
|
|
||||||
// Theme is still applied locally even if API save fails
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setLocalTheme]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
style={{
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
<span style={{ fontSize: "0.83rem", color: "var(--muted)", margin: "0 6px" }}>/</span>
|
|
||||||
<span style={{ fontSize: "0.83rem", color: "var(--text-2)" }}>Appearance</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page header */}
|
|
||||||
<div style={{ marginBottom: 32 }}>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: "1.875rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Appearance
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: "8px 0 0 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Choose a theme for the Mosaic interface
|
|
||||||
{saving && (
|
|
||||||
<span style={{ marginLeft: 12, color: "var(--primary-l)", fontStyle: "italic" }}>
|
|
||||||
Saving...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme grid */}
|
|
||||||
<div
|
|
||||||
className="grid gap-3"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* System option first */}
|
|
||||||
<SystemThemeCard
|
|
||||||
isActive={preference === "system"}
|
|
||||||
onSelect={() => void handleThemeSelect("system")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* All registered themes */}
|
|
||||||
{allThemes.map((t) => (
|
|
||||||
<ThemeCard
|
|
||||||
key={t.id}
|
|
||||||
theme={t}
|
|
||||||
isActive={preference === t.id}
|
|
||||||
onSelect={() => void handleThemeSelect(t.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
|
|
||||||
const ACTIVITY_ACTIONS = [
|
const ACTIVITY_ACTIONS = [
|
||||||
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
||||||
@@ -40,17 +39,17 @@ export default function CredentialAuditPage(): React.ReactElement {
|
|||||||
const [filters, setFilters] = useState<FilterState>({});
|
const [filters, setFilters] = useState<FilterState>({});
|
||||||
const [hasFilters, setHasFilters] = useState(false);
|
const [hasFilters, setHasFilters] = useState(false);
|
||||||
|
|
||||||
const workspaceId = useWorkspaceId();
|
// TODO: Get workspace ID from context/auth
|
||||||
|
const workspaceId = "default-workspace-id"; // Placeholder
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) return;
|
void loadLogs();
|
||||||
void loadLogs(workspaceId);
|
}, [page, filters]);
|
||||||
}, [workspaceId, page, filters]);
|
|
||||||
|
|
||||||
async function loadLogs(wsId: string): Promise<void> {
|
async function loadLogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetchCredentialAuditLog(wsId, {
|
const response = await fetchCredentialAuditLog(workspaceId, {
|
||||||
...filters,
|
...filters,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,383 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, type SyntheticEvent } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import type { Domain } from "@mosaic/shared";
|
import type { Domain } from "@mosaic/shared";
|
||||||
import { DomainList } from "@/components/domains/DomainList";
|
import { DomainList } from "@/components/domains/DomainList";
|
||||||
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
|
import { fetchDomains, deleteDomain } from "@/lib/api/domains";
|
||||||
import type { CreateDomainDto } from "@/lib/api/domains";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
export default function DomainsPage(): React.ReactElement {
|
||||||
Slug generation helper
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
function generateSlug(name: string): string {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9\s-]/g, "")
|
|
||||||
.replace(/\s+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.slice(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Create Domain Dialog
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
interface CreateDomainDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSubmit: (data: CreateDomainDto) => Promise<void>;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateDomainDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSubmit,
|
|
||||||
isSubmitting,
|
|
||||||
}: CreateDomainDialogProps): ReactElement | null {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [slug, setSlug] = useState("");
|
|
||||||
const [slugTouched, setSlugTouched] = useState(false);
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function resetForm(): void {
|
|
||||||
setName("");
|
|
||||||
setSlug("");
|
|
||||||
setSlugTouched(false);
|
|
||||||
setDescription("");
|
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNameChange(value: string): void {
|
|
||||||
setName(value);
|
|
||||||
if (!slugTouched) {
|
|
||||||
setSlug(generateSlug(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSlugChange(value: string): void {
|
|
||||||
setSlugTouched(true);
|
|
||||||
setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
const trimmedName = name.trim();
|
|
||||||
if (!trimmedName) {
|
|
||||||
setFormError("Domain name is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedSlug = slug.trim();
|
|
||||||
if (!trimmedSlug) {
|
|
||||||
setFormError("Slug is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
|
|
||||||
setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
|
|
||||||
const trimmedDesc = description.trim();
|
|
||||||
if (trimmedDesc) {
|
|
||||||
payload.description = trimmedDesc;
|
|
||||||
}
|
|
||||||
await onSubmit(payload);
|
|
||||||
resetForm();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setFormError(err instanceof Error ? err.message : "Failed to create domain.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="create-domain-title"
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
zIndex: 50,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
background: "rgba(0,0,0,0.5)",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
resetForm();
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dialog */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
background: "var(--surface, #fff)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
border: "1px solid var(--border, #e5e7eb)",
|
|
||||||
padding: 24,
|
|
||||||
width: "100%",
|
|
||||||
maxWidth: 480,
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
id="create-domain-title"
|
|
||||||
style={{
|
|
||||||
fontSize: "1.125rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text, #111)",
|
|
||||||
margin: "0 0 8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Domain
|
|
||||||
</h2>
|
|
||||||
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
|
|
||||||
Domains help you organize tasks, projects, and events by life area.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
void handleSubmit(e);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="domain-name"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="domain-name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleNameChange(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="e.g. Personal Finance"
|
|
||||||
maxLength={255}
|
|
||||||
autoFocus
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "var(--bg, #f9fafb)",
|
|
||||||
border: "1px solid var(--border, #d1d5db)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text, #111)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Slug */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="domain-slug"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Slug <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="domain-slug"
|
|
||||||
type="text"
|
|
||||||
value={slug}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleSlugChange(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="e.g. personal-finance"
|
|
||||||
maxLength={100}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "var(--bg, #f9fafb)",
|
|
||||||
border: "1px solid var(--border, #d1d5db)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text, #111)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
fontFamily: "var(--mono, monospace)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--muted, #6b7280)",
|
|
||||||
margin: "4px 0 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Lowercase letters, numbers, and hyphens only.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<label
|
|
||||||
htmlFor="domain-description"
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: 6,
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="domain-description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDescription(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="A brief summary of this domain..."
|
|
||||||
rows={3}
|
|
||||||
maxLength={10000}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
background: "var(--bg, #f9fafb)",
|
|
||||||
border: "1px solid var(--border, #d1d5db)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text, #111)",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
outline: "none",
|
|
||||||
resize: "vertical",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form error */}
|
|
||||||
{formError !== null && (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
color: "var(--danger, #ef4444)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
margin: "0 0 12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
resetForm();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid var(--border, #d1d5db)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text-2, #374151)",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !name.trim() || !slug.trim()}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
background: "var(--primary, #111827)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: isSubmitting || !name.trim() || !slug.trim() ? "not-allowed" : "pointer",
|
|
||||||
opacity: isSubmitting || !name.trim() || !slug.trim() ? 0.6 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Creating..." : "Create Domain"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
Domains Page
|
|
||||||
--------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
export default function DomainsPage(): ReactElement {
|
|
||||||
const workspaceId = useWorkspaceId();
|
|
||||||
const [domains, setDomains] = useState<Domain[]>([]);
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Create dialog state
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadDomains();
|
void loadDomains();
|
||||||
}, [workspaceId]);
|
}, []);
|
||||||
|
|
||||||
async function loadDomains(): Promise<void> {
|
async function loadDomains(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetchDomains(undefined, workspaceId ?? undefined);
|
const response = await fetchDomains();
|
||||||
setDomains(response.data);
|
setDomains(response.data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -387,8 +27,9 @@ export default function DomainsPage(): ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(_domain: Domain): void {
|
function handleEdit(domain: Domain): void {
|
||||||
// TODO: Open edit modal/form
|
// TODO: Open edit modal/form
|
||||||
|
console.log("Edit domain:", domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(domain: Domain): Promise<void> {
|
async function handleDelete(domain: Domain): Promise<void> {
|
||||||
@@ -397,26 +38,13 @@ export default function DomainsPage(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteDomain(domain.id, workspaceId ?? undefined);
|
await deleteDomain(domain.id);
|
||||||
await loadDomains();
|
await loadDomains();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(data: CreateDomainDto): Promise<void> {
|
|
||||||
setIsCreating(true);
|
|
||||||
try {
|
|
||||||
await createDomain(data, workspaceId ?? undefined);
|
|
||||||
setCreateOpen(false);
|
|
||||||
await loadDomains();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to create domain.");
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-6">
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -432,7 +60,7 @@ export default function DomainsPage(): ReactElement {
|
|||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreateOpen(true);
|
console.log("TODO: Open create modal");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create Domain
|
Create Domain
|
||||||
@@ -445,13 +73,6 @@ export default function DomainsPage(): ReactElement {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateDomainDialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
onSubmit={handleCreate}
|
|
||||||
isSubmitting={isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { ReactElement, ReactNode } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface CategoryConfig {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
href: string;
|
|
||||||
accent: string;
|
|
||||||
iconBg: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsCategoryCardProps {
|
|
||||||
category: CategoryConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsCategoryCard({ category }: SettingsCategoryCardProps): ReactElement {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={category.href} style={{ textDecoration: "none" }}>
|
|
||||||
<div
|
|
||||||
onMouseEnter={(): void => {
|
|
||||||
setHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={(): void => {
|
|
||||||
setHovered(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
|
||||||
border: `1px solid ${hovered ? category.accent : "var(--border)"}`,
|
|
||||||
borderRadius: "var(--r-lg)",
|
|
||||||
padding: 20,
|
|
||||||
transition: "background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease",
|
|
||||||
boxShadow: hovered ? "0 4px 16px rgba(0,0,0,0.2)" : "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 12,
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Icon well */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: category.iconBg,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
color: category.accent,
|
|
||||||
transition: "transform 0.15s ease",
|
|
||||||
transform: hovered ? "scale(1.05)" : "scale(1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{category.icon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div style={{ fontSize: "1rem", fontWeight: 700, color: "var(--text)" }}>
|
|
||||||
{category.title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
lineHeight: 1.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{category.description}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "0.83rem",
|
|
||||||
color: hovered ? category.accent : "var(--muted)",
|
|
||||||
fontWeight: 500,
|
|
||||||
marginTop: "auto",
|
|
||||||
transition: "color 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Manage →
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories: CategoryConfig[] = [
|
|
||||||
{
|
|
||||||
title: "Appearance",
|
|
||||||
description:
|
|
||||||
"Choose a theme for the interface. Switch between Dark, Light, Nord, Dracula, and more.",
|
|
||||||
href: "/settings/appearance",
|
|
||||||
accent: "var(--ms-pink-500)",
|
|
||||||
iconBg: "rgba(236, 72, 153, 0.12)",
|
|
||||||
icon: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="10" cy="10" r="7.5" />
|
|
||||||
<path d="M10 2.5v15" />
|
|
||||||
<path d="M10 2.5a7.5 7.5 0 0 1 0 15" fill="currentColor" opacity="0.15" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Credentials",
|
|
||||||
description:
|
|
||||||
"Securely store and manage API keys, tokens, and passwords used by agents and integrations.",
|
|
||||||
href: "/settings/credentials",
|
|
||||||
accent: "var(--ms-blue-400)",
|
|
||||||
iconBg: "rgba(47, 128, 255, 0.12)",
|
|
||||||
icon: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<rect x="5" y="9" width="10" height="8" rx="1.5" />
|
|
||||||
<path d="M7 9V6a3 3 0 0 1 6 0v3" />
|
|
||||||
<circle cx="10" cy="13" r="1" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Domains",
|
|
||||||
description:
|
|
||||||
"Organize tasks and projects by life areas or functional domains within your workspace.",
|
|
||||||
href: "/settings/domains",
|
|
||||||
accent: "var(--ms-teal-400)",
|
|
||||||
iconBg: "rgba(20, 184, 166, 0.12)",
|
|
||||||
icon: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="10" cy="10" r="7.5" />
|
|
||||||
<line x1="2.5" y1="10" x2="17.5" y2="10" />
|
|
||||||
<path d="M10 2.5c2 2.5 3 5 3 7.5s-1 5-3 7.5" />
|
|
||||||
<path d="M10 2.5c-2 2.5-3 5-3 7.5s1 5 3 7.5" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "AI Personalities",
|
|
||||||
description:
|
|
||||||
"Customize how the AI assistant communicates \u2014 tone, formality, and response style.",
|
|
||||||
href: "/settings/personalities",
|
|
||||||
accent: "var(--ms-purple-400)",
|
|
||||||
iconBg: "rgba(139, 92, 246, 0.12)",
|
|
||||||
icon: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="10" cy="6" r="3" />
|
|
||||||
<path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
|
||||||
<path d="M14 10l1.5 1.5 3-3" stroke="currentColor" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Workspaces",
|
|
||||||
description:
|
|
||||||
"Create and manage workspaces to organize projects and collaborate with your team.",
|
|
||||||
href: "/settings/workspaces",
|
|
||||||
accent: "var(--ms-amber-400)",
|
|
||||||
iconBg: "rgba(245, 158, 11, 0.12)",
|
|
||||||
icon: (
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="10" cy="10" r="2" />
|
|
||||||
<circle cx="4" cy="5" r="1.5" />
|
|
||||||
<circle cx="16" cy="5" r="1.5" />
|
|
||||||
<circle cx="16" cy="15" r="1.5" />
|
|
||||||
<line x1="8.3" y1="8.7" x2="5.3" y2="6.2" />
|
|
||||||
<line x1="11.7" y1="8.7" x2="14.7" y2="6.2" />
|
|
||||||
<line x1="11.7" y1="11.3" x2="14.7" y2="13.8" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsPage(): ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto p-6">
|
|
||||||
{/* Page header */}
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: "1.875rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: "8px 0 0 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Configure your workspace, credentials, and preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<SettingsCategoryCard key={category.href} category={category} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import type { Task } from "@mosaic/shared";
|
|
||||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
|
||||||
import TasksPage from "./page";
|
import TasksPage from "./page";
|
||||||
|
|
||||||
// Mock the TaskList component
|
// Mock the TaskList component
|
||||||
@@ -12,121 +9,21 @@ vi.mock("@/components/tasks/TaskList", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock MosaicSpinner
|
|
||||||
vi.mock("@/components/ui/MosaicSpinner", () => ({
|
|
||||||
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
|
|
||||||
<div data-testid="mosaic-spinner">{label ?? "Loading..."}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock useWorkspaceId
|
|
||||||
const mockUseWorkspaceId = vi.fn<() => string | null>();
|
|
||||||
vi.mock("@/lib/hooks", () => ({
|
|
||||||
useWorkspaceId: (): string | null => mockUseWorkspaceId(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fetchTasks
|
|
||||||
const mockFetchTasks = vi.fn<() => Promise<Task[]>>();
|
|
||||||
vi.mock("@/lib/api/tasks", () => ({
|
|
||||||
fetchTasks: (...args: unknown[]): Promise<Task[]> => mockFetchTasks(...(args as [])),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fakeTasks: Task[] = [
|
|
||||||
{
|
|
||||||
id: "task-1",
|
|
||||||
title: "Test task 1",
|
|
||||||
description: "Description 1",
|
|
||||||
status: TaskStatus.IN_PROGRESS,
|
|
||||||
priority: TaskPriority.HIGH,
|
|
||||||
dueDate: new Date("2026-02-01"),
|
|
||||||
creatorId: "user-1",
|
|
||||||
assigneeId: "user-1",
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
projectId: null,
|
|
||||||
parentId: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
metadata: {},
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "task-2",
|
|
||||||
title: "Test task 2",
|
|
||||||
description: "Description 2",
|
|
||||||
status: TaskStatus.NOT_STARTED,
|
|
||||||
priority: TaskPriority.MEDIUM,
|
|
||||||
dueDate: new Date("2026-02-02"),
|
|
||||||
creatorId: "user-1",
|
|
||||||
assigneeId: "user-1",
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
projectId: null,
|
|
||||||
parentId: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
metadata: {},
|
|
||||||
completedAt: null,
|
|
||||||
createdAt: new Date("2026-01-28"),
|
|
||||||
updatedAt: new Date("2026-01-28"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("TasksPage", (): void => {
|
describe("TasksPage", (): void => {
|
||||||
beforeEach((): void => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockUseWorkspaceId.mockReturnValue("ws-1");
|
|
||||||
mockFetchTasks.mockResolvedValue(fakeTasks);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the page title", (): void => {
|
it("should render the page title", (): void => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show loading spinner initially", (): void => {
|
it("should show loading state initially", (): void => {
|
||||||
// Never resolve so we stay in loading state
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
mockFetchTasks.mockReturnValue(new Promise<Task[]>(() => {}));
|
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByTestId("mosaic-spinner")).toBeInTheDocument();
|
expect(screen.getByTestId("task-list")).toHaveTextContent("Loading");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
it("should render the TaskList with tasks after loading", async (): Promise<void> => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
|
expect(screen.getByTestId("task-list")).toHaveTextContent("4 tasks");
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show empty state when no tasks exist", async (): Promise<void> => {
|
|
||||||
mockFetchTasks.mockResolvedValue([]);
|
|
||||||
render(<TasksPage />);
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("No tasks found")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show error state on API failure", async (): Promise<void> => {
|
|
||||||
mockFetchTasks.mockRejectedValue(new Error("Network error"));
|
|
||||||
render(<TasksPage />);
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByRole("button", { name: /try again/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should retry fetching on retry button click", async (): Promise<void> => {
|
|
||||||
mockFetchTasks.mockRejectedValueOnce(new Error("Network error"));
|
|
||||||
render(<TasksPage />);
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
mockFetchTasks.mockResolvedValueOnce(fakeTasks);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
await user.click(screen.getByRole("button", { name: /try again/i }));
|
|
||||||
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(screen.getByTestId("task-list")).toHaveTextContent("2 tasks");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,14 +37,4 @@ describe("TasksPage", (): void => {
|
|||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
expect(screen.getByText("Organize your work at your own pace")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not fetch when workspace ID is not available", async (): Promise<void> => {
|
|
||||||
mockUseWorkspaceId.mockReturnValue(null);
|
|
||||||
render(<TasksPage />);
|
|
||||||
|
|
||||||
// Wait a tick to ensure useEffect ran
|
|
||||||
await waitFor((): void => {
|
|
||||||
expect(mockFetchTasks).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,123 +4,57 @@ import { useState, useEffect } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
import { TaskList } from "@/components/tasks/TaskList";
|
import { TaskList } from "@/components/tasks/TaskList";
|
||||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
import { mockTasks } from "@/lib/api/tasks";
|
||||||
import { fetchTasks } from "@/lib/api/tasks";
|
|
||||||
import { useWorkspaceId } from "@/lib/hooks";
|
|
||||||
import type { Task } from "@mosaic/shared";
|
import type { Task } from "@mosaic/shared";
|
||||||
|
|
||||||
export default function TasksPage(): ReactElement {
|
export default function TasksPage(): ReactElement {
|
||||||
const workspaceId = useWorkspaceId();
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
async function loadTasks(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const filters = workspaceId !== null ? { workspaceId } : {};
|
|
||||||
const data = await fetchTasks(filters);
|
|
||||||
if (!cancelled) {
|
|
||||||
setTasks(data);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error("[Tasks] Failed to fetch tasks:", err);
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "We had trouble loading your tasks. Please try again when you're ready."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadTasks();
|
void loadTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (): void => {
|
async function loadTasks(): Promise<void> {
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [workspaceId]);
|
|
||||||
|
|
||||||
function handleRetry(): void {
|
|
||||||
if (!workspaceId) return;
|
|
||||||
setError(null);
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
fetchTasks({ workspaceId })
|
try {
|
||||||
.then((data) => {
|
// TODO: Replace with real API call when backend is ready
|
||||||
setTasks(data);
|
// const data = await fetchTasks();
|
||||||
})
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
.catch((err: unknown) => {
|
setTasks(mockTasks);
|
||||||
console.error("[Tasks] Retry failed:", err);
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error
|
err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
: "We had trouble loading your tasks. Please try again when you're ready."
|
: "We had trouble loading your tasks. Please try again when you're ready."
|
||||||
);
|
);
|
||||||
})
|
} finally {
|
||||||
.finally(() => {
|
setIsLoading(false);
|
||||||
setIsLoading(false);
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--text)" }}>
|
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
||||||
Tasks
|
<p className="text-gray-600 mt-2">Organize your work at your own pace</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-2" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Organize your work at your own pace
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{error !== null ? (
|
||||||
<div className="flex justify-center py-16">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||||
<MosaicSpinner label="Loading tasks..." />
|
<p className="text-amber-800">{error}</p>
|
||||||
</div>
|
|
||||||
) : error !== null ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-6 text-center"
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--danger)" }}>{error}</p>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRetry}
|
onClick={() => void loadTasks()}
|
||||||
className="mt-4 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||||
style={{ background: "var(--danger)" }}
|
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : tasks.length === 0 ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-8 text-center"
|
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ color: "var(--text-muted)" }}>No tasks found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<TaskList tasks={tasks} isLoading={false} />
|
<TaskList tasks={tasks} isLoading={isLoading} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminal page — dedicated full-screen terminal route at /terminal.
|
|
||||||
*
|
|
||||||
* Renders the TerminalPanel component filling the available content area.
|
|
||||||
* The panel is always open on this page; there is no close action since
|
|
||||||
* the user navigates away using the sidebar instead.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import { TerminalPanel } from "@/components/terminal";
|
|
||||||
import { getAccessToken } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
export default function TerminalPage(): ReactElement {
|
|
||||||
const [token, setToken] = useState<string>("");
|
|
||||||
|
|
||||||
// Resolve the access token once on mount. The WebSocket connection inside
|
|
||||||
// TerminalPanel uses this token for authentication.
|
|
||||||
useEffect((): void => {
|
|
||||||
getAccessToken()
|
|
||||||
.then((t) => {
|
|
||||||
setToken(t ?? "");
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error("[TerminalPage] Failed to retrieve access token:", err);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Override TerminalPanel inline height so it fills the page */}
|
|
||||||
<style>{`
|
|
||||||
.terminal-page-panel {
|
|
||||||
height: 100% !important;
|
|
||||||
border-top: none !important;
|
|
||||||
flex: 1 !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
aria-label="Terminal"
|
|
||||||
>
|
|
||||||
<TerminalPanel
|
|
||||||
open={true}
|
|
||||||
onClose={(): void => {
|
|
||||||
/* No-op: on the dedicated terminal page the panel is always open.
|
|
||||||
Users navigate away using the sidebar rather than closing the panel. */
|
|
||||||
}}
|
|
||||||
token={token}
|
|
||||||
className="terminal-page-panel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 539 B |
@@ -3,303 +3,147 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
MOSAIC DESIGN SYSTEM — Reference token system from dashboard design
|
DESIGN C: PROFESSIONAL/ENTERPRISE DESIGN SYSTEM
|
||||||
|
Philosophy: "Good design is as little design as possible." - Dieter Rams
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Primitive Tokens (Dark-first — dark is the default theme)
|
CSS Custom Properties - Light Theme (Default)
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
/* Mosaic design tokens — dark palette (default) */
|
/* Base colors - increased contrast from surfaces */
|
||||||
--ms-bg-950: #080b12;
|
--color-background: 245 247 250;
|
||||||
--ms-bg-900: #0f141d;
|
--color-foreground: 15 23 42;
|
||||||
--ms-bg-850: #151b26;
|
|
||||||
--ms-surface-800: #1b2331;
|
|
||||||
--ms-surface-750: #232d3f;
|
|
||||||
--ms-border-700: #2f3b52;
|
|
||||||
--ms-text-100: #eef3ff;
|
|
||||||
--ms-text-300: #c5d0e6;
|
|
||||||
--ms-text-500: #8f9db7;
|
|
||||||
--ms-blue-500: #2f80ff;
|
|
||||||
--ms-blue-400: #56a0ff;
|
|
||||||
--ms-red-500: #e5484d;
|
|
||||||
--ms-red-400: #f06a6f;
|
|
||||||
--ms-purple-500: #8b5cf6;
|
|
||||||
--ms-purple-400: #a78bfa;
|
|
||||||
--ms-teal-500: #14b8a6;
|
|
||||||
--ms-teal-400: #2dd4bf;
|
|
||||||
--ms-amber-500: #f59e0b;
|
|
||||||
--ms-amber-400: #fbbf24;
|
|
||||||
--ms-pink-500: #ec4899;
|
|
||||||
--ms-emerald-500: #10b981;
|
|
||||||
--ms-orange-500: #f97316;
|
|
||||||
--ms-cyan-500: #06b6d4;
|
|
||||||
--ms-indigo-500: #6366f1;
|
|
||||||
|
|
||||||
/* Semantic aliases — dark theme is default */
|
/* Surface hierarchy (elevation levels) - improved contrast */
|
||||||
--bg: var(--ms-bg-900);
|
--surface-0: 255 255 255;
|
||||||
--bg-deep: var(--ms-bg-950);
|
--surface-1: 250 251 252;
|
||||||
--bg-mid: var(--ms-bg-850);
|
--surface-2: 241 245 249;
|
||||||
--surface: var(--ms-surface-800);
|
--surface-3: 226 232 240;
|
||||||
--surface-2: var(--ms-surface-750);
|
|
||||||
--border: var(--ms-border-700);
|
|
||||||
--text: var(--ms-text-100);
|
|
||||||
--text-2: var(--ms-text-300);
|
|
||||||
--muted: var(--ms-text-500);
|
|
||||||
--primary: var(--ms-blue-500);
|
|
||||||
--primary-l: var(--ms-blue-400);
|
|
||||||
--danger: var(--ms-red-500);
|
|
||||||
--success: var(--ms-teal-500);
|
|
||||||
--warn: var(--ms-amber-500);
|
|
||||||
--purple: var(--ms-purple-500);
|
|
||||||
|
|
||||||
/* Typography */
|
/* Text hierarchy */
|
||||||
--font: var(--font-outfit, 'Outfit'), system-ui, sans-serif;
|
--text-primary: 15 23 42;
|
||||||
--mono: var(--font-fira-code, 'Fira Code'), 'Cascadia Code', monospace;
|
--text-secondary: 51 65 85;
|
||||||
|
--text-tertiary: 71 85 105;
|
||||||
|
--text-muted: 100 116 139;
|
||||||
|
|
||||||
/* Radius scale */
|
/* Border colors - stronger borders for light mode */
|
||||||
--r: 8px;
|
--border-default: 203 213 225;
|
||||||
--r-sm: 5px;
|
--border-subtle: 226 232 240;
|
||||||
--r-lg: 12px;
|
--border-strong: 148 163 184;
|
||||||
--r-xl: 16px;
|
|
||||||
|
|
||||||
/* Layout dimensions */
|
/* Brand accent - Indigo (professional, trustworthy) */
|
||||||
--sidebar-w: 260px;
|
--accent-primary: 79 70 229;
|
||||||
--topbar-h: 56px;
|
--accent-primary-hover: 67 56 202;
|
||||||
--terminal-h: 220px;
|
--accent-primary-light: 238 242 255;
|
||||||
|
--accent-primary-muted: 199 210 254;
|
||||||
|
|
||||||
/* Easing */
|
/* Semantic colors - Success (Emerald) */
|
||||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
--semantic-success: 16 185 129;
|
||||||
|
--semantic-success-light: 209 250 229;
|
||||||
|
--semantic-success-dark: 6 95 70;
|
||||||
|
|
||||||
/* Legacy shadow tokens (retained for component compat) */
|
/* Semantic colors - Warning (Amber) */
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
--semantic-warning: 245 158 11;
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
--semantic-warning-light: 254 243 199;
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
--semantic-warning-dark: 146 64 14;
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* Semantic colors - Error (Rose) */
|
||||||
Light Theme Override — applied via data-theme attribute on <html>
|
--semantic-error: 244 63 94;
|
||||||
----------------------------------------------------------------------------- */
|
--semantic-error-light: 255 228 230;
|
||||||
[data-theme="light"] {
|
--semantic-error-dark: 159 18 57;
|
||||||
--ms-bg-950: #f8faff;
|
|
||||||
--ms-bg-900: #f0f4fc;
|
|
||||||
--ms-bg-850: #e8edf8;
|
|
||||||
--ms-surface-800: #dde4f2;
|
|
||||||
--ms-surface-750: #d0d9ec;
|
|
||||||
--ms-border-700: #b8c4de;
|
|
||||||
--ms-text-100: #0f141d;
|
|
||||||
--ms-text-300: #2f3b52;
|
|
||||||
--ms-text-500: #5a6a87;
|
|
||||||
|
|
||||||
/* Re-alias semantics for light — identical structure, primitive tokens differ */
|
/* Semantic colors - Info (Sky) */
|
||||||
--bg: var(--ms-bg-900);
|
--semantic-info: 14 165 233;
|
||||||
--bg-deep: var(--ms-bg-950);
|
--semantic-info-light: 224 242 254;
|
||||||
--bg-mid: var(--ms-bg-850);
|
--semantic-info-dark: 3 105 161;
|
||||||
--surface: var(--ms-surface-800);
|
|
||||||
--surface-2: var(--ms-surface-750);
|
|
||||||
--border: var(--ms-border-700);
|
|
||||||
--text: var(--ms-text-100);
|
|
||||||
--text-2: var(--ms-text-300);
|
|
||||||
--muted: var(--ms-text-500);
|
|
||||||
|
|
||||||
/* Lighter shadows for light mode */
|
/* Focus ring */
|
||||||
|
--focus-ring: 99 102 241;
|
||||||
|
--focus-ring-offset: 255 255 255;
|
||||||
|
|
||||||
|
/* Shadows - visible but subtle */
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
CSS Custom Properties - Dark Theme
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
.dark {
|
||||||
|
--color-background: 3 7 18;
|
||||||
|
--color-foreground: 248 250 252;
|
||||||
|
|
||||||
|
/* Surface hierarchy (elevation levels) */
|
||||||
|
--surface-0: 15 23 42;
|
||||||
|
--surface-1: 30 41 59;
|
||||||
|
--surface-2: 51 65 85;
|
||||||
|
--surface-3: 71 85 105;
|
||||||
|
|
||||||
|
/* Text hierarchy */
|
||||||
|
--text-primary: 248 250 252;
|
||||||
|
--text-secondary: 203 213 225;
|
||||||
|
--text-tertiary: 148 163 184;
|
||||||
|
--text-muted: 100 116 139;
|
||||||
|
|
||||||
|
/* Border colors */
|
||||||
|
--border-default: 51 65 85;
|
||||||
|
--border-subtle: 30 41 59;
|
||||||
|
--border-strong: 71 85 105;
|
||||||
|
|
||||||
|
/* Brand accent adjustments for dark mode */
|
||||||
|
--accent-primary: 129 140 248;
|
||||||
|
--accent-primary-hover: 165 180 252;
|
||||||
|
--accent-primary-light: 30 27 75;
|
||||||
|
--accent-primary-muted: 55 48 163;
|
||||||
|
|
||||||
|
/* Semantic colors adjustments */
|
||||||
|
--semantic-success: 52 211 153;
|
||||||
|
--semantic-success-light: 6 78 59;
|
||||||
|
--semantic-success-dark: 167 243 208;
|
||||||
|
|
||||||
|
--semantic-warning: 251 191 36;
|
||||||
|
--semantic-warning-light: 120 53 15;
|
||||||
|
--semantic-warning-dark: 253 230 138;
|
||||||
|
|
||||||
|
--semantic-error: 251 113 133;
|
||||||
|
--semantic-error-light: 136 19 55;
|
||||||
|
--semantic-error-dark: 253 164 175;
|
||||||
|
|
||||||
|
--semantic-info: 56 189 248;
|
||||||
|
--semantic-info-light: 12 74 110;
|
||||||
|
--semantic-info-dark: 186 230 253;
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
--focus-ring: 129 140 248;
|
||||||
|
--focus-ring-offset: 15 23 42;
|
||||||
|
|
||||||
|
/* Shadows - subtle glow in dark mode */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Base Styles
|
Base Styles
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
*,
|
* {
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 15px;
|
|
||||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font);
|
color: rgb(var(--text-primary));
|
||||||
background: var(--bg);
|
background: rgb(var(--color-background));
|
||||||
color: var(--text);
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtle grain/noise overlay for texture */
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
|
|
||||||
opacity: 0.025;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Focus States - Accessible & Visible
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
@layer base {
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--ms-blue-400);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus:not(:focus-visible) {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Scrollbar Styling - Minimal & Professional
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox */
|
|
||||||
* {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
App Shell Grid Layout
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
.app-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: var(--sidebar-w) 1fr;
|
|
||||||
grid-template-rows: var(--topbar-h) 1fr;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 1;
|
|
||||||
background: var(--bg-deep);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 20px;
|
|
||||||
gap: 12px;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 2;
|
|
||||||
background: var(--bg-deep);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 2;
|
|
||||||
background: var(--bg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Responsive App Shell — Mobile (< 768px): single-column, sidebar as overlay
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.app-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: var(--topbar-h);
|
|
||||||
bottom: 0;
|
|
||||||
width: 240px;
|
|
||||||
z-index: 150;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar[data-mobile-open="true"] {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Responsive App Shell — Tablet (768px–1023px): sidebar toggleable, pushes content
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
|
||||||
.app-shell[data-sidebar-hidden="true"] {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell[data-sidebar-hidden="true"] .app-sidebar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell[data-sidebar-hidden="true"] .app-main {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell[data-sidebar-hidden="true"] .app-header {
|
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
@@ -338,10 +182,102 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-mono {
|
.text-mono {
|
||||||
font-family: var(--mono);
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text color utilities */
|
||||||
|
.text-primary {
|
||||||
|
color: rgb(var(--text-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: rgb(var(--text-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tertiary {
|
||||||
|
color: rgb(var(--text-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: rgb(var(--text-muted));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Surface & Card Utilities
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@layer utilities {
|
||||||
|
.surface-0 {
|
||||||
|
background-color: rgb(var(--surface-0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-1 {
|
||||||
|
background-color: rgb(var(--surface-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-2 {
|
||||||
|
background-color: rgb(var(--surface-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-3 {
|
||||||
|
background-color: rgb(var(--surface-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-default {
|
||||||
|
border-color: rgb(var(--border-default));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-subtle {
|
||||||
|
border-color: rgb(var(--border-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-strong {
|
||||||
|
border-color: rgb(var(--border-strong));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Focus States - Accessible & Visible
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@layer base {
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid rgb(var(--focus-ring));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default focus for mouse users */
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Scrollbar Styling - Minimal & Professional
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(var(--text-muted) / 0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(var(--text-muted) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(var(--text-muted) / 0.4) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
@@ -356,46 +292,40 @@ body::before {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background: linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500));
|
background-color: rgb(var(--accent-primary));
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: var(--r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
box-shadow: 0 8px 28px rgba(47, 128, 255, 0.38);
|
background-color: rgb(var(--accent-primary-hover));
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: var(--surface);
|
background-color: rgb(var(--surface-2));
|
||||||
color: var(--text-2);
|
color: rgb(var(--text-primary));
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgb(var(--border-default));
|
||||||
border-radius: var(--r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background-color: var(--surface-2);
|
background-color: rgb(var(--surface-3));
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply btn px-3 py-2;
|
@apply btn px-3 py-2;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--muted);
|
color: rgb(var(--text-secondary));
|
||||||
border-radius: var(--r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
background-color: var(--surface);
|
background-color: rgb(var(--surface-2));
|
||||||
color: var(--text);
|
color: rgb(var(--text-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: var(--danger);
|
background-color: rgb(var(--semantic-error));
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: var(--r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
@@ -416,36 +346,34 @@ body::before {
|
|||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
@layer components {
|
@layer components {
|
||||||
.input {
|
.input {
|
||||||
@apply w-full text-sm transition-all duration-150;
|
@apply w-full rounded-md px-3 py-2 text-sm transition-all duration-150;
|
||||||
@apply focus:outline-none;
|
@apply focus:outline-none focus:ring-2 focus:ring-offset-0;
|
||||||
background-color: var(--bg);
|
background-color: rgb(var(--surface-0));
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgb(var(--border-default));
|
||||||
border-radius: var(--r);
|
color: rgb(var(--text-primary));
|
||||||
color: var(--text);
|
|
||||||
padding: 11px 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
color: var(--muted);
|
color: rgb(var(--text-muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: var(--primary);
|
border-color: rgb(var(--accent-primary));
|
||||||
box-shadow: 0 0 0 3px rgba(47, 128, 255, 0.12);
|
box-shadow: 0 0 0 3px rgb(var(--accent-primary) / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:disabled {
|
.input:disabled {
|
||||||
@apply opacity-50 cursor-not-allowed;
|
@apply opacity-50 cursor-not-allowed;
|
||||||
background-color: var(--surface);
|
background-color: rgb(var(--surface-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-error {
|
.input-error {
|
||||||
border-color: var(--danger);
|
border-color: rgb(var(--semantic-error));
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-error:focus {
|
.input-error:focus {
|
||||||
border-color: var(--danger);
|
border-color: rgb(var(--semantic-error));
|
||||||
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.12);
|
box-shadow: 0 0 0 3px rgb(var(--semantic-error) / 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,8 +383,8 @@ body::before {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
@apply rounded-lg p-4;
|
@apply rounded-lg p-4;
|
||||||
background-color: var(--surface);
|
background-color: rgb(var(--surface-0));
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgb(var(--border-default));
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +398,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-interactive:hover {
|
.card-interactive:hover {
|
||||||
border-color: var(--muted);
|
border-color: rgb(var(--border-strong));
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,33 +412,33 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background-color: rgba(20, 184, 166, 0.15);
|
background-color: rgb(var(--semantic-success-light));
|
||||||
color: var(--ms-teal-400);
|
color: rgb(var(--semantic-success-dark));
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-warning {
|
.badge-warning {
|
||||||
background-color: rgba(245, 158, 11, 0.15);
|
background-color: rgb(var(--semantic-warning-light));
|
||||||
color: var(--ms-amber-400);
|
color: rgb(var(--semantic-warning-dark));
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-error {
|
.badge-error {
|
||||||
background-color: rgba(229, 72, 77, 0.15);
|
background-color: rgb(var(--semantic-error-light));
|
||||||
color: var(--ms-red-400);
|
color: rgb(var(--semantic-error-dark));
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-info {
|
.badge-info {
|
||||||
background-color: rgba(47, 128, 255, 0.15);
|
background-color: rgb(var(--semantic-info-light));
|
||||||
color: var(--ms-blue-400);
|
color: rgb(var(--semantic-info-dark));
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-neutral {
|
.badge-neutral {
|
||||||
background-color: var(--surface-2);
|
background-color: rgb(var(--surface-2));
|
||||||
color: var(--text-2);
|
color: rgb(var(--text-secondary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
background-color: rgba(47, 128, 255, 0.15);
|
background-color: rgb(var(--accent-primary-light));
|
||||||
color: var(--primary-l);
|
color: rgb(var(--accent-primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,29 +451,26 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-success {
|
.status-dot-success {
|
||||||
background-color: var(--success);
|
background-color: rgb(var(--semantic-success));
|
||||||
box-shadow: 0 0 5px var(--success);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-warning {
|
.status-dot-warning {
|
||||||
background-color: var(--warn);
|
background-color: rgb(var(--semantic-warning));
|
||||||
box-shadow: 0 0 5px var(--warn);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-error {
|
.status-dot-error {
|
||||||
background-color: var(--danger);
|
background-color: rgb(var(--semantic-error));
|
||||||
box-shadow: 0 0 5px var(--danger);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-info {
|
.status-dot-info {
|
||||||
background-color: var(--primary);
|
background-color: rgb(var(--semantic-info));
|
||||||
box-shadow: 0 0 5px var(--primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-neutral {
|
.status-dot-neutral {
|
||||||
background-color: var(--muted);
|
background-color: rgb(var(--text-muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pulsing indicator for live/active status */
|
||||||
.status-dot-pulse {
|
.status-dot-pulse {
|
||||||
@apply relative;
|
@apply relative;
|
||||||
}
|
}
|
||||||
@@ -564,12 +489,12 @@ body::before {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.kbd {
|
.kbd {
|
||||||
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
||||||
background-color: var(--surface-2);
|
background-color: rgb(var(--surface-2));
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgb(var(--border-default));
|
||||||
color: var(--muted);
|
color: rgb(var(--text-tertiary));
|
||||||
font-family: var(--mono);
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
min-width: 1.5rem;
|
min-width: 1.5rem;
|
||||||
box-shadow: 0 1px 0 var(--border);
|
box-shadow: 0 1px 0 rgb(var(--border-strong));
|
||||||
}
|
}
|
||||||
|
|
||||||
.kbd-group {
|
.kbd-group {
|
||||||
@@ -587,13 +512,13 @@ body::before {
|
|||||||
|
|
||||||
.table-pro thead {
|
.table-pro thead {
|
||||||
@apply sticky top-0;
|
@apply sticky top-0;
|
||||||
background-color: var(--surface);
|
background-color: rgb(var(--surface-1));
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid rgb(var(--border-default));
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th {
|
.table-pro th {
|
||||||
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
||||||
color: var(--muted);
|
color: rgb(var(--text-tertiary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th.sortable {
|
.table-pro th.sortable {
|
||||||
@@ -601,16 +526,16 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th.sortable:hover {
|
.table-pro th.sortable:hover {
|
||||||
color: var(--text);
|
color: rgb(var(--text-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro tbody tr {
|
.table-pro tbody tr {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid rgb(var(--border-subtle));
|
||||||
transition: background-color 0.1s ease;
|
transition: background-color 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro tbody tr:hover {
|
.table-pro tbody tr:hover {
|
||||||
background-color: var(--surface);
|
background-color: rgb(var(--surface-1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro td {
|
.table-pro td {
|
||||||
@@ -630,9 +555,9 @@ body::before {
|
|||||||
@apply animate-pulse rounded;
|
@apply animate-pulse rounded;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--surface) 0%,
|
rgb(var(--surface-2)) 0%,
|
||||||
var(--surface-2) 50%,
|
rgb(var(--surface-1)) 50%,
|
||||||
var(--surface) 100%
|
rgb(var(--surface-2)) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
@@ -665,16 +590,15 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto;
|
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg;
|
||||||
background-color: var(--surface);
|
background-color: rgb(var(--surface-0));
|
||||||
border: 1px solid var(--border);
|
border: 1px solid rgb(var(--border-default));
|
||||||
border-radius: var(--r-lg);
|
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@apply flex items-center justify-between p-4 border-b;
|
@apply flex items-center justify-between p-4 border-b;
|
||||||
border-color: var(--border);
|
border-color: rgb(var(--border-default));
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@@ -683,7 +607,7 @@ body::before {
|
|||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@apply flex items-center justify-end gap-3 p-4 border-t;
|
@apply flex items-center justify-end gap-3 p-4 border-t;
|
||||||
border-color: var(--border);
|
border-color: rgb(var(--border-default));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,10 +617,9 @@ body::before {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
||||||
background-color: var(--text);
|
background-color: rgb(var(--text-primary));
|
||||||
color: var(--bg);
|
color: rgb(var(--color-background));
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
border-radius: var(--r-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip::before {
|
.tooltip::before {
|
||||||
@@ -707,7 +630,7 @@ body::before {
|
|||||||
|
|
||||||
.tooltip-top::before {
|
.tooltip-top::before {
|
||||||
@apply left-1/2 top-full -translate-x-1/2;
|
@apply left-1/2 top-full -translate-x-1/2;
|
||||||
border-top-color: var(--text);
|
border-top-color: rgb(var(--text-primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,92 +680,16 @@ body::before {
|
|||||||
animation: scaleIn 0.15s ease-out;
|
animation: scaleIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Message animation - subtle for chat */
|
||||||
.message-animate {
|
.message-animate {
|
||||||
animation: slideIn 0.2s ease-out;
|
animation: slideIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Menu dropdown animation */
|
||||||
.animate-menu-enter {
|
.animate-menu-enter {
|
||||||
animation: scaleIn 0.1s ease-out;
|
animation: scaleIn 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Streaming cursor for real-time token rendering */
|
|
||||||
@keyframes streaming-cursor-blink {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 2px;
|
|
||||||
height: 1em;
|
|
||||||
background-color: rgb(var(--accent-primary));
|
|
||||||
border-radius: 1px;
|
|
||||||
animation: streaming-cursor-blink 1s step-end infinite;
|
|
||||||
vertical-align: text-bottom;
|
|
||||||
margin-left: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Dashboard Layout — Responsive Grids
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
.metrics-strip {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(var(--ms-cols, 6), 1fr);
|
|
||||||
gap: 0;
|
|
||||||
border-radius: var(--r-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-cell {
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-cell:first-child {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.metrics-strip {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-cell:nth-child(3n + 1) {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.metrics-strip {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-cell:nth-child(3n + 1) {
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-cell:nth-child(2n + 1) {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 320px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.dash-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Responsive Typography Adjustments
|
Responsive Typography Adjustments
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
@@ -863,8 +710,13 @@ body::before {
|
|||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
@media (prefers-contrast: high) {
|
@media (prefers-contrast: high) {
|
||||||
:root {
|
:root {
|
||||||
--border: #4a5a78;
|
--border-default: 100 116 139;
|
||||||
--muted: #a0b0cc;
|
--border-strong: 71 85 105;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--border-default: 148 163 184;
|
||||||
|
--border-strong: 203 213 225;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Outfit, Fira_Code } from "next/font/google";
|
|
||||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Mosaic Stack",
|
title: "Mosaic Stack",
|
||||||
description: "Mosaic Stack Web Application",
|
description: "Mosaic Stack Web Application",
|
||||||
icons: {
|
|
||||||
icon: "/favicon.ico",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const outfit = Outfit({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-outfit",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
const firaCode = Fira_Code({
|
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-fira-code",
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runtime env vars injected as a synchronous script so client-side modules
|
|
||||||
* can read them before React hydration. This allows Docker env vars to
|
|
||||||
* override the build-time baked NEXT_PUBLIC_* values.
|
|
||||||
*/
|
|
||||||
function runtimeEnvScript(): string {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const key of [
|
|
||||||
"NEXT_PUBLIC_API_URL",
|
|
||||||
"NEXT_PUBLIC_ORCHESTRATOR_URL",
|
|
||||||
"NEXT_PUBLIC_AUTH_MODE",
|
|
||||||
]) {
|
|
||||||
const value = process.env[key];
|
|
||||||
if (value) {
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `window.__MOSAIC_ENV__=${JSON.stringify(env)};`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${outfit.variable} ${firaCode.variable}`}>
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: runtimeEnvScript() }} />
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import type { ReactElement } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "404 — Page Not Found | Mosaic Stack",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function NotFound(): ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
minHeight: "100vh",
|
|
||||||
background: "var(--bg)",
|
|
||||||
padding: "24px",
|
|
||||||
textAlign: "center",
|
|
||||||
gap: "32px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Mosaic logo mark — inline spans replicating the 5-element logo */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
position: "relative",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
role="img"
|
|
||||||
aria-label="Mosaic logo"
|
|
||||||
>
|
|
||||||
{/* Top-left: blue */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 19,
|
|
||||||
height: 19,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--ms-blue-500)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Top-right: purple */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
width: 19,
|
|
||||||
height: 19,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--ms-purple-500)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Bottom-right: teal */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
width: 19,
|
|
||||||
height: 19,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--ms-teal-500)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Bottom-left: amber */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 19,
|
|
||||||
height: 19,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--ms-amber-500)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Center: pink circle */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: 15,
|
|
||||||
height: 15,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: "var(--ms-pink-500)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 404 gradient text */}
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: "6rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
margin: 0,
|
|
||||||
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
|
|
||||||
WebkitBackgroundClip: "text",
|
|
||||||
WebkitTextFillColor: "transparent",
|
|
||||||
backgroundClip: "text",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
404
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Heading + description */}
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text)",
|
|
||||||
margin: 0,
|
|
||||||
letterSpacing: "-0.025em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Page not found
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: "0.9375rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: 0,
|
|
||||||
maxWidth: "400px",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
The page you're looking for doesn't exist or has been moved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dashboard link styled as button */}
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "10px 24px",
|
|
||||||
background: "var(--ms-blue-500)",
|
|
||||||
color: "#ffffff",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
textDecoration: "none",
|
|
||||||
transition: "opacity 0.15s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Go to Dashboard
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Subtle status footer */}
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--mono)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
color: "var(--muted)",
|
|
||||||
margin: 0,
|
|
||||||
opacity: 0.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
HTTP 404 — Not Found
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
52
apps/web/src/app/page.test.tsx
Normal file
52
apps/web/src/app/page.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import Home from "./page";
|
||||||
|
|
||||||
|
// Mock Next.js navigation
|
||||||
|
const mockPush = vi.fn();
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: (): {
|
||||||
|
push: typeof mockPush;
|
||||||
|
replace: ReturnType<typeof vi.fn>;
|
||||||
|
prefetch: ReturnType<typeof vi.fn>;
|
||||||
|
} => ({
|
||||||
|
push: mockPush,
|
||||||
|
replace: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock auth context
|
||||||
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
|
useAuth: (): {
|
||||||
|
user: null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
signOut: ReturnType<typeof vi.fn>;
|
||||||
|
refreshSession: ReturnType<typeof vi.fn>;
|
||||||
|
} => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
signOut: vi.fn(),
|
||||||
|
refreshSession: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Home", (): void => {
|
||||||
|
beforeEach((): void => {
|
||||||
|
mockPush.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render loading spinner", (): void => {
|
||||||
|
const { container } = render(<Home />);
|
||||||
|
// The home page shows a loading spinner while redirecting
|
||||||
|
const spinner = container.querySelector(".animate-spin");
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect unauthenticated users to login", (): void => {
|
||||||
|
render(<Home />);
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
28
apps/web/src/app/page.tsx
Normal file
28
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
|
||||||
|
export default function Home(): ReactElement {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.push("/tasks");
|
||||||
|
} else {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { AuthDivider } from "./AuthDivider";
|
|||||||
describe("AuthDivider", (): void => {
|
describe("AuthDivider", (): void => {
|
||||||
it("should render with default text", (): void => {
|
it("should render with default text", (): void => {
|
||||||
render(<AuthDivider />);
|
render(<AuthDivider />);
|
||||||
expect(screen.getByText("or continue with")).toBeInTheDocument();
|
expect(screen.getByText("or continue with email")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render with custom text", (): void => {
|
it("should render with custom text", (): void => {
|
||||||
@@ -13,10 +13,10 @@ describe("AuthDivider", (): void => {
|
|||||||
expect(screen.getByText("or sign up")).toBeInTheDocument();
|
expect(screen.getByText("or sign up")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render horizontal divider lines", (): void => {
|
it("should render a horizontal divider line", (): void => {
|
||||||
const { container } = render(<AuthDivider />);
|
const { container } = render(<AuthDivider />);
|
||||||
const lines = container.querySelectorAll("[aria-hidden='true'].h-px");
|
const line = container.querySelector("span.border-t");
|
||||||
expect(lines.length).toBe(2);
|
expect(line).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply uppercase styling to text", (): void => {
|
it("should apply uppercase styling to text", (): void => {
|
||||||
|
|||||||
@@ -1,2 +1,18 @@
|
|||||||
export { AuthDivider } from "@mosaic/ui";
|
interface AuthDividerProps {
|
||||||
export type { AuthDividerProps } from "@mosaic/ui";
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthDivider({
|
||||||
|
text = "or continue with email",
|
||||||
|
}: AuthDividerProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t border-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-white px-2 text-slate-500">{text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user