Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881ef7b46a | ||
|
|
63fde7993c | ||
|
|
ba483cae97 | ||
|
|
f2f9ab74c8 | ||
|
|
7e62c0162f | ||
|
|
329cc0e57a | ||
|
|
da6b8f70c5 | ||
|
|
a3c1bb075c | ||
|
|
af13f054e4 | ||
|
|
0a4af3b758 | ||
|
|
5856c1c732 | ||
|
|
0862420e7c | ||
|
|
00b968953e | ||
|
|
780bd019ea | ||
|
|
582355a1f2 | ||
|
|
0797f8f99c | ||
|
|
26f41a8122 | ||
|
|
a03aa0a4c4 |
+62
-18
@@ -8,6 +8,10 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
|
||||||
|
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
shared:
|
shared:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -108,25 +112,65 @@ jobs:
|
|||||||
PLAYWRIGHT_TEST_PASS: ${{ secrets.STAGE_TEST_PASS }}
|
PLAYWRIGHT_TEST_PASS: ${{ secrets.STAGE_TEST_PASS }}
|
||||||
|
|
||||||
image:
|
image:
|
||||||
|
# Builds the portal image and ships it through the same path every
|
||||||
|
# other service in orca-infra uses: push :latest + :sha-<sha> to
|
||||||
|
# repo.breakpilot.com, then POST a github-style payload to the
|
||||||
|
# orca webhook so the master pulls and redeploys the portal services.
|
||||||
needs: [shared, test]
|
needs: [shared, test]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('Dockerfile') != ''
|
# NOTE: no hashFiles() here — at job level it evaluates BEFORE checkout
|
||||||
|
# against an empty workspace, so the old condition was always false and
|
||||||
|
# this job silently never ran (deployment sat on a June-10 image).
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
# The runner's default job container has no docker CLI, so
|
||||||
|
# docker/login-action + docker/build-push-action die with "Unable to
|
||||||
|
# locate executable file: docker" (proven by tenant-registry run 5441).
|
||||||
|
# Same pattern as the proven breakpilot-compliance build-push-deploy.yml:
|
||||||
|
# run in docker:27-cli and use plain docker commands.
|
||||||
|
container: docker:27-cli
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
- uses: docker/login-action@v3
|
run: |
|
||||||
with:
|
apk add --no-cache git curl openssl
|
||||||
registry: registry.breakpilot.com
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} $(echo ${GITHUB_SERVER_URL} | sed -E "s#^(https?://)#\1ci:${{ secrets.GITHUB_TOKEN }}@#")/${GITHUB_REPOSITORY}.git .
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
- name: Login
|
||||||
- uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
registry.breakpilot.com/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
|
||||||
registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
|
|
||||||
- uses: anchore/sbom-action@v0
|
|
||||||
with:
|
|
||||||
image: registry.breakpilot.com/${{ github.event.repository.name }}:sha-${{ github.sha }}
|
|
||||||
- run: orca apply --env=stage --image-tag=sha-${{ github.sha }}
|
|
||||||
env:
|
env:
|
||||||
ORCA_TOKEN: ${{ secrets.ORCA_STAGE_TOKEN }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
run: echo "$REGISTRY_PASS" | docker login repo.breakpilot.com -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
- name: Build + push
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
docker build \
|
||||||
|
-t repo.breakpilot.com/breakpilot/portal:latest \
|
||||||
|
-t repo.breakpilot.com/breakpilot/portal:sha-${GITHUB_SHA} \
|
||||||
|
.
|
||||||
|
docker push repo.breakpilot.com/breakpilot/portal:latest
|
||||||
|
docker push repo.breakpilot.com/breakpilot/portal:sha-${GITHUB_SHA}
|
||||||
|
|
||||||
|
- name: sign image (cosign)
|
||||||
|
run: |
|
||||||
|
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
|
||||||
|
chmod +x /usr/local/bin/cosign 2>/dev/null || true
|
||||||
|
cosign sign --yes --key env://COSIGN_KEY repo.breakpilot.com/breakpilot/portal:latest || echo "::warning::cosign failed"
|
||||||
|
|
||||||
|
- name: trigger orca redeploy
|
||||||
|
# Signs the POST with HMAC-SHA256 over the JSON body using the
|
||||||
|
# secret orca generated when the webhook was registered. Orca's
|
||||||
|
# endpoint is publicly reachable on the master, so the signature
|
||||||
|
# gates who can fire a deploy. Plain http like the proven
|
||||||
|
# compliance trigger-orca job (the endpoint does not serve TLS).
|
||||||
|
env:
|
||||||
|
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
BODY='{"repository":{"full_name":"platform/portal"},"ref":"refs/heads/main"}'
|
||||||
|
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -hex | awk '{print $NF}')"
|
||||||
|
curl -ksSf -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-GitHub-Event: push" \
|
||||||
|
-H "X-Hub-Signature-256: $SIG" \
|
||||||
|
-d "$BODY" \
|
||||||
|
http://46.225.100.82:6880/api/v1/webhooks/github
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
- name: verify stage soak (>= 24h on this image)
|
- name: verify stage soak (>= 24h on this image)
|
||||||
run: |
|
run: |
|
||||||
IMG=registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
|
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
|
||||||
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
|
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
|
||||||
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
if [ "$SOAK_SECONDS" -lt 86400 ]; then
|
||||||
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
|
||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
- name: re-tag image as semver + env-prod
|
- name: re-tag image as semver + env-prod
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: registry.breakpilot.com
|
registry: repo.breakpilot.com
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
password: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
IMG=registry.breakpilot.com/${{ github.event.repository.name }}
|
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}
|
||||||
docker pull $IMG:env-stage
|
docker pull $IMG:env-stage
|
||||||
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
|
||||||
docker tag $IMG:env-stage $IMG:env-prod
|
docker tag $IMG:env-stage $IMG:env-prod
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
|
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
|
||||||
https://gitea.meghsakha.com/api/v1/repos/${{ github.repository }}/releases
|
https://git.breakpilot.com/api/v1/repos/${{ github.repository }}/releases
|
||||||
|
|
||||||
rollback-on-failure:
|
rollback-on-failure:
|
||||||
needs: promote
|
needs: promote
|
||||||
|
|||||||
+8
-1
@@ -6,7 +6,14 @@ const config = [
|
|||||||
...nextWebVitals,
|
...nextWebVitals,
|
||||||
...nextTypescript,
|
...nextTypescript,
|
||||||
{
|
{
|
||||||
ignores: [".next/**", "node_modules/**", "coverage/**", "next-env.d.ts"],
|
ignores: [
|
||||||
|
".next/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"coverage/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
// Auto-generated by `msw init` — patched on every MSW upgrade.
|
||||||
|
"public/mockServiceWorker.js",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -23,8 +23,8 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.7.2",
|
"geist": "^1.7.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.11",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0"
|
||||||
@@ -43,5 +43,18 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "5.7.2",
|
"typescript": "5.7.2",
|
||||||
"vitest": "2.1.8"
|
"vitest": "2.1.8"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"public"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"nanoid@<3.3.18": ">=3.3.18",
|
||||||
|
"postcss@<8.5.18": ">=8.5.18",
|
||||||
|
"@auth/core@<0.41.3": ">=0.41.3",
|
||||||
|
"sharp@<0.35.0": ">=0.35.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+304
-284
@@ -4,6 +4,12 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
nanoid@<3.3.18: '>=3.3.18'
|
||||||
|
postcss@<8.5.18: '>=8.5.18'
|
||||||
|
'@auth/core@<0.41.3': '>=0.41.3'
|
||||||
|
sharp@<0.35.0: '>=0.35.0'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -16,16 +22,16 @@ importers:
|
|||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
geist:
|
geist:
|
||||||
specifier: ^1.7.2
|
specifier: ^1.7.2
|
||||||
version: 1.7.2(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
|
version: 1.7.2(next@16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.17.0
|
specifier: ^1.17.0
|
||||||
version: 1.17.0(react@19.0.0)
|
version: 1.17.0(react@19.0.0)
|
||||||
next:
|
next:
|
||||||
specifier: 16.2.6
|
specifier: 16.2.11
|
||||||
version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
next-auth:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.25
|
specifier: 5.0.0-beta.32
|
||||||
version: 5.0.0-beta.25(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
version: 5.0.0-beta.32(next@16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||||
react:
|
react:
|
||||||
specifier: 19.0.0
|
specifier: 19.0.0
|
||||||
version: 19.0.0
|
version: 19.0.0
|
||||||
@@ -64,8 +70,8 @@ importers:
|
|||||||
specifier: ^2.14.6
|
specifier: ^2.14.6
|
||||||
version: 2.14.6(@types/node@20.16.10)(typescript@5.7.2)
|
version: 2.14.6(@types/node@20.16.10)(typescript@5.7.2)
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.5.15
|
specifier: '>=8.5.18'
|
||||||
version: 8.5.15
|
version: 8.5.26
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
@@ -86,12 +92,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@auth/core@0.37.2':
|
'@auth/core@0.41.3':
|
||||||
resolution: {integrity: sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==}
|
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@simplewebauthn/browser': ^9.0.1
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
'@simplewebauthn/server': ^9.0.2
|
'@simplewebauthn/server': ^9.0.2
|
||||||
nodemailer: ^6.8.0
|
nodemailer: ^7.0.7 || ^8.0.5
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@simplewebauthn/browser':
|
'@simplewebauthn/browser':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -176,6 +182,9 @@ packages:
|
|||||||
'@emnapi/runtime@1.10.0':
|
'@emnapi/runtime@1.10.0':
|
||||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.3':
|
||||||
|
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.2.1':
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||||
|
|
||||||
@@ -379,136 +388,145 @@ packages:
|
|||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.34.5':
|
'@img/sharp-darwin-arm64@0.35.4':
|
||||||
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
|
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/sharp-darwin-x64@0.34.5':
|
'@img/sharp-darwin-x64@0.35.4':
|
||||||
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
|
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||||
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
|
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||||
|
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||||
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
|
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linux-arm64@0.34.5':
|
'@img/sharp-linux-arm64@0.35.4':
|
||||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linux-arm@0.34.5':
|
'@img/sharp-linux-arm@0.35.4':
|
||||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linux-ppc64@0.34.5':
|
'@img/sharp-linux-ppc64@0.35.4':
|
||||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linux-riscv64@0.34.5':
|
'@img/sharp-linux-riscv64@0.35.4':
|
||||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linux-s390x@0.34.5':
|
'@img/sharp-linux-s390x@0.35.4':
|
||||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.5':
|
'@img/sharp-linux-x64@0.35.4':
|
||||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@img/sharp-wasm32@0.35.4':
|
||||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
|
|
||||||
|
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||||
|
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
|
||||||
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [wasm32]
|
cpu: [wasm32]
|
||||||
|
|
||||||
'@img/sharp-win32-arm64@0.34.5':
|
'@img/sharp-win32-arm64@0.35.4':
|
||||||
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
|
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@img/sharp-win32-ia32@0.34.5':
|
'@img/sharp-win32-ia32@0.35.4':
|
||||||
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
|
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^20.9.0}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@img/sharp-win32-x64@0.34.5':
|
'@img/sharp-win32-x64@0.35.4':
|
||||||
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
|
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -581,56 +599,56 @@ packages:
|
|||||||
'@emnapi/core': ^1.7.1
|
'@emnapi/core': ^1.7.1
|
||||||
'@emnapi/runtime': ^1.7.1
|
'@emnapi/runtime': ^1.7.1
|
||||||
|
|
||||||
'@next/env@16.2.6':
|
'@next/env@16.2.11':
|
||||||
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
|
resolution: {integrity: sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.2.6':
|
'@next/eslint-plugin-next@16.2.6':
|
||||||
resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==}
|
resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.2.6':
|
'@next/swc-darwin-arm64@16.2.11':
|
||||||
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
|
resolution: {integrity: sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.2.6':
|
'@next/swc-darwin-x64@16.2.11':
|
||||||
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
|
resolution: {integrity: sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.2.6':
|
'@next/swc-linux-arm64-gnu@16.2.11':
|
||||||
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
|
resolution: {integrity: sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.2.6':
|
'@next/swc-linux-arm64-musl@16.2.11':
|
||||||
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
|
resolution: {integrity: sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.2.6':
|
'@next/swc-linux-x64-gnu@16.2.11':
|
||||||
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
|
resolution: {integrity: sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.2.6':
|
'@next/swc-linux-x64-musl@16.2.11':
|
||||||
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
|
resolution: {integrity: sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.2.6':
|
'@next/swc-win32-arm64-msvc@16.2.11':
|
||||||
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
|
resolution: {integrity: sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.2.6':
|
'@next/swc-win32-x64-msvc@16.2.11':
|
||||||
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
|
resolution: {integrity: sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -897,9 +915,6 @@ packages:
|
|||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||||
|
|
||||||
'@types/cookie@0.6.0':
|
|
||||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1315,10 +1330,6 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
cookie@0.7.1:
|
|
||||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
cookie@1.1.1:
|
cookie@1.1.1:
|
||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1931,8 +1942,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jose@5.10.0:
|
jose@6.2.10:
|
||||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
@@ -2134,9 +2145,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
nanoid@3.3.12:
|
nanoid@6.0.1:
|
||||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^22 || ^24 || >=26}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
napi-postinstall@0.3.4:
|
napi-postinstall@0.3.4:
|
||||||
@@ -2147,14 +2158,14 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
next-auth@5.0.0-beta.25:
|
next-auth@5.0.0-beta.32:
|
||||||
resolution: {integrity: sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==}
|
resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@simplewebauthn/browser': ^9.0.1
|
'@simplewebauthn/browser': ^9.0.1
|
||||||
'@simplewebauthn/server': ^9.0.2
|
'@simplewebauthn/server': ^9.0.2
|
||||||
next: ^14.0.0-0 || ^15.0.0-0
|
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
|
||||||
nodemailer: ^6.6.5
|
nodemailer: ^7.0.7 || ^8.0.5
|
||||||
react: ^18.2.0 || ^19.0.0-0
|
react: ^18.2.0 || ^19.0.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@simplewebauthn/browser':
|
'@simplewebauthn/browser':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -2163,8 +2174,8 @@ packages:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
next@16.2.6:
|
next@16.2.11:
|
||||||
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
|
resolution: {integrity: sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2302,29 +2313,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.5.26:
|
||||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
postcss@8.5.15:
|
preact-render-to-string@6.5.11:
|
||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
|
||||||
|
|
||||||
preact-render-to-string@5.2.3:
|
|
||||||
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
preact: '>=10'
|
preact: '>=10'
|
||||||
|
|
||||||
preact@10.11.3:
|
preact@10.24.3:
|
||||||
resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
|
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
pretty-format@3.8.0:
|
|
||||||
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
|
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -2410,6 +2414,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.8.5:
|
||||||
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
set-cookie-parser@3.1.0:
|
set-cookie-parser@3.1.0:
|
||||||
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||||
|
|
||||||
@@ -2425,9 +2434,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.35.4:
|
||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: '>=20.9.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
@@ -2813,15 +2827,13 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@auth/core@0.37.2':
|
'@auth/core@0.41.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@panva/hkdf': 1.2.1
|
'@panva/hkdf': 1.2.1
|
||||||
'@types/cookie': 0.6.0
|
jose: 6.2.10
|
||||||
cookie: 0.7.1
|
|
||||||
jose: 5.10.0
|
|
||||||
oauth4webapi: 3.8.6
|
oauth4webapi: 3.8.6
|
||||||
preact: 10.11.3
|
preact: 10.24.3
|
||||||
preact-render-to-string: 5.2.3(preact@10.11.3)
|
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2936,6 +2948,11 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.3':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.2.1':
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -3075,98 +3092,108 @@ snapshots:
|
|||||||
'@img/colour@1.1.0':
|
'@img/colour@1.1.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-darwin-arm64@0.34.5':
|
'@img/sharp-darwin-arm64@0.35.4':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-darwin-x64@0.34.5':
|
'@img/sharp-darwin-x64@0.35.4':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-libvips-darwin-arm64@1.2.4':
|
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-darwin-x64@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm64@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linux-arm64@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linux-arm@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linux-ppc64@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linux-riscv64@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linux-s390x@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linux-x64@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
|
||||||
optionalDependencies:
|
|
||||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/runtime': 1.10.0
|
'@img/sharp-wasm32': 0.35.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-win32-arm64@0.34.5':
|
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-win32-ia32@0.34.5':
|
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-win32-x64@0.34.5':
|
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm64@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-arm@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-ppc64@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-riscv64@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-s390x@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linux-x64@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||||
|
optionalDependencies:
|
||||||
|
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-wasm32@0.35.4':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/runtime': 1.11.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||||
|
dependencies:
|
||||||
|
'@img/sharp-wasm32': 0.35.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-arm64@0.35.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-ia32@0.35.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@img/sharp-win32-x64@0.35.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@inquirer/ansi@2.0.7': {}
|
'@inquirer/ansi@2.0.7': {}
|
||||||
@@ -3242,34 +3269,34 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.2
|
'@tybys/wasm-util': 0.10.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.2.6': {}
|
'@next/env@16.2.11': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.2.6':
|
'@next/eslint-plugin-next@16.2.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.2.6':
|
'@next/swc-darwin-arm64@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.2.6':
|
'@next/swc-darwin-x64@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.2.6':
|
'@next/swc-linux-arm64-gnu@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.2.6':
|
'@next/swc-linux-arm64-musl@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.2.6':
|
'@next/swc-linux-x64-gnu@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.2.6':
|
'@next/swc-linux-x64-musl@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.2.6':
|
'@next/swc-win32-arm64-msvc@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.2.6':
|
'@next/swc-win32-x64-msvc@16.2.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
@@ -3453,7 +3480,7 @@ snapshots:
|
|||||||
'@alloc/quick-lru': 5.2.0
|
'@alloc/quick-lru': 5.2.0
|
||||||
'@tailwindcss/node': 4.3.0
|
'@tailwindcss/node': 4.3.0
|
||||||
'@tailwindcss/oxide': 4.3.0
|
'@tailwindcss/oxide': 4.3.0
|
||||||
postcss: 8.5.15
|
postcss: 8.5.26
|
||||||
tailwindcss: 4.3.0
|
tailwindcss: 4.3.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.2':
|
'@tybys/wasm-util@0.10.2':
|
||||||
@@ -3461,8 +3488,6 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@types/cookie@0.6.0': {}
|
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
@@ -3910,8 +3935,6 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
cookie@0.7.1: {}
|
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
@@ -4415,9 +4438,9 @@ snapshots:
|
|||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
geist@1.7.2(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)):
|
geist@1.7.2(next@16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
next: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
next: 16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
@@ -4695,7 +4718,7 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
jose@5.10.0: {}
|
jose@6.2.10: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
@@ -4878,41 +4901,42 @@ snapshots:
|
|||||||
|
|
||||||
mute-stream@3.0.0: {}
|
mute-stream@3.0.0: {}
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@6.0.1: {}
|
||||||
|
|
||||||
napi-postinstall@0.3.4: {}
|
napi-postinstall@0.3.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
next-auth@5.0.0-beta.25(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
|
next-auth@5.0.0-beta.32(next@16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@auth/core': 0.37.2
|
'@auth/core': 0.41.3
|
||||||
next: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
next: 16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
|
|
||||||
next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
next@16.2.11(@babel/core@7.29.0)(@playwright/test@1.60.0)(@types/node@20.16.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.6
|
'@next/env': 16.2.11
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
baseline-browser-mapping: 2.10.31
|
baseline-browser-mapping: 2.10.31
|
||||||
caniuse-lite: 1.0.30001793
|
caniuse-lite: 1.0.30001793
|
||||||
postcss: 8.4.31
|
postcss: 8.5.26
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.0.0)
|
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.0.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.2.6
|
'@next/swc-darwin-arm64': 16.2.11
|
||||||
'@next/swc-darwin-x64': 16.2.6
|
'@next/swc-darwin-x64': 16.2.11
|
||||||
'@next/swc-linux-arm64-gnu': 16.2.6
|
'@next/swc-linux-arm64-gnu': 16.2.11
|
||||||
'@next/swc-linux-arm64-musl': 16.2.6
|
'@next/swc-linux-arm64-musl': 16.2.11
|
||||||
'@next/swc-linux-x64-gnu': 16.2.6
|
'@next/swc-linux-x64-gnu': 16.2.11
|
||||||
'@next/swc-linux-x64-musl': 16.2.6
|
'@next/swc-linux-x64-musl': 16.2.11
|
||||||
'@next/swc-win32-arm64-msvc': 16.2.6
|
'@next/swc-win32-arm64-msvc': 16.2.11
|
||||||
'@next/swc-win32-x64-msvc': 16.2.6
|
'@next/swc-win32-x64-msvc': 16.2.11
|
||||||
'@playwright/test': 1.60.0
|
'@playwright/test': 1.60.0
|
||||||
sharp: 0.34.5
|
sharp: 0.35.4(@types/node@20.16.10)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
|
- '@types/node'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
node-exports-info@1.6.0:
|
node-exports-info@1.6.0:
|
||||||
@@ -5032,29 +5056,20 @@ snapshots:
|
|||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.5.26:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.12
|
nanoid: 6.0.1
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
postcss@8.5.15:
|
preact-render-to-string@6.5.11(preact@10.24.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.12
|
preact: 10.24.3
|
||||||
picocolors: 1.1.1
|
|
||||||
source-map-js: 1.2.1
|
|
||||||
|
|
||||||
preact-render-to-string@5.2.3(preact@10.11.3):
|
preact@10.24.3: {}
|
||||||
dependencies:
|
|
||||||
preact: 10.11.3
|
|
||||||
pretty-format: 3.8.0
|
|
||||||
|
|
||||||
preact@10.11.3: {}
|
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
pretty-format@3.8.0: {}
|
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -5173,6 +5188,9 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.0: {}
|
semver@7.8.0: {}
|
||||||
|
|
||||||
|
semver@7.8.5:
|
||||||
|
optional: true
|
||||||
|
|
||||||
set-cookie-parser@3.1.0: {}
|
set-cookie-parser@3.1.0: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
@@ -5197,36 +5215,38 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.35.4(@types/node@20.16.10):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.1.0
|
'@img/colour': 1.1.0
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
semver: 7.8.0
|
semver: 7.8.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-darwin-arm64': 0.34.5
|
'@img/sharp-darwin-arm64': 0.35.4
|
||||||
'@img/sharp-darwin-x64': 0.34.5
|
'@img/sharp-darwin-x64': 0.35.4
|
||||||
'@img/sharp-libvips-darwin-arm64': 1.2.4
|
'@img/sharp-freebsd-wasm32': 0.35.4
|
||||||
'@img/sharp-libvips-darwin-x64': 1.2.4
|
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||||
'@img/sharp-libvips-linux-arm': 1.2.4
|
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||||
'@img/sharp-libvips-linux-arm64': 1.2.4
|
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||||
'@img/sharp-libvips-linux-ppc64': 1.2.4
|
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||||
'@img/sharp-libvips-linux-riscv64': 1.2.4
|
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||||
'@img/sharp-libvips-linux-s390x': 1.2.4
|
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||||
'@img/sharp-libvips-linux-x64': 1.2.4
|
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||||
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
|
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||||
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
|
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||||
'@img/sharp-linux-arm': 0.34.5
|
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||||
'@img/sharp-linux-arm64': 0.34.5
|
'@img/sharp-linux-arm': 0.35.4
|
||||||
'@img/sharp-linux-ppc64': 0.34.5
|
'@img/sharp-linux-arm64': 0.35.4
|
||||||
'@img/sharp-linux-riscv64': 0.34.5
|
'@img/sharp-linux-ppc64': 0.35.4
|
||||||
'@img/sharp-linux-s390x': 0.34.5
|
'@img/sharp-linux-riscv64': 0.35.4
|
||||||
'@img/sharp-linux-x64': 0.34.5
|
'@img/sharp-linux-s390x': 0.35.4
|
||||||
'@img/sharp-linuxmusl-arm64': 0.34.5
|
'@img/sharp-linux-x64': 0.35.4
|
||||||
'@img/sharp-linuxmusl-x64': 0.34.5
|
'@img/sharp-linuxmusl-arm64': 0.35.4
|
||||||
'@img/sharp-wasm32': 0.34.5
|
'@img/sharp-linuxmusl-x64': 0.35.4
|
||||||
'@img/sharp-win32-arm64': 0.34.5
|
'@img/sharp-webcontainers-wasm32': 0.35.4
|
||||||
'@img/sharp-win32-ia32': 0.34.5
|
'@img/sharp-win32-arm64': 0.35.4
|
||||||
'@img/sharp-win32-x64': 0.34.5
|
'@img/sharp-win32-ia32': 0.35.4
|
||||||
|
'@img/sharp-win32-x64': 0.35.4
|
||||||
|
'@types/node': 20.16.10
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
@@ -5548,7 +5568,7 @@ snapshots:
|
|||||||
vite@5.4.21(@types/node@20.16.10)(lightningcss@1.32.0):
|
vite@5.4.21(@types/node@20.16.10)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.15
|
postcss: 8.5.26
|
||||||
rollup: 4.60.4
|
rollup: 4.60.4
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.16.10
|
'@types/node': 20.16.10
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = '2.14.6'
|
||||||
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('message', async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, 'id')
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === 'only-if-cached' &&
|
||||||
|
event.request.mode !== 'same-origin'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const requestCloneForEvents = event.request.clone()
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = response.clone()
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: responseClone.type,
|
||||||
|
status: responseClone.status,
|
||||||
|
statusText: responseClone.statusText,
|
||||||
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||||
|
body: responseClone.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get('accept')
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== 'msw/passthrough',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set('accept', filteredValues.join(', '))
|
||||||
|
} else {
|
||||||
|
headers.delete('accept')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request)
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSTHROUGH': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response)
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mockedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import { Lifeline } from "@/components/portal/Lifeline";
|
|||||||
import { NavRail } from "@/components/portal/NavRail";
|
import { NavRail } from "@/components/portal/NavRail";
|
||||||
import { Topbar } from "@/components/portal/Topbar";
|
import { Topbar } from "@/components/portal/Topbar";
|
||||||
import { ArchivedLockout } from "@/components/portal/ArchivedLockout";
|
import { ArchivedLockout } from "@/components/portal/ArchivedLockout";
|
||||||
|
import { MockWorker } from "@/components/portal/MockWorker";
|
||||||
|
import { ToastHost } from "@/components/portal/ToastHost";
|
||||||
|
|
||||||
|
const MOCK_API = !!process.env.BP_DEV_FIXTURE;
|
||||||
|
|
||||||
export default async function TenantLayout({
|
export default async function TenantLayout({
|
||||||
children,
|
children,
|
||||||
@@ -50,6 +54,17 @@ export default async function TenantLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
{MOCK_API ? (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `window.__BP_MOCK_API__=true;window.__BP_TENANT_STATUS__=${JSON.stringify(tenant.status)};`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MockWorker />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<ToastHost />
|
||||||
<Lifeline
|
<Lifeline
|
||||||
tenant={{
|
tenant={{
|
||||||
status: tenant.status,
|
status: tenant.status,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getPortalSession } from "@/lib/get-session";
|
|||||||
import { loadTenantForShell } from "@/lib/portal-data";
|
import { loadTenantForShell } from "@/lib/portal-data";
|
||||||
import { Panel } from "@/components/portal/Panel";
|
import { Panel } from "@/components/portal/Panel";
|
||||||
import { NotAllowed } from "@/components/portal/NotAllowed";
|
import { NotAllowed } from "@/components/portal/NotAllowed";
|
||||||
|
import { InviteButton } from "@/components/portal/InviteButton";
|
||||||
|
|
||||||
export default async function TeamPage({
|
export default async function TeamPage({
|
||||||
params,
|
params,
|
||||||
@@ -28,7 +29,7 @@ export default async function TeamPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="ph-actions">
|
<div className="ph-actions">
|
||||||
<button type="button" className="btn">Export</button>
|
<button type="button" className="btn">Export</button>
|
||||||
<button type="button" className="btn btn-accent">Invite member</button>
|
<InviteButton tenantStatus={t.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Workflows is full-bleed — the editor (palette + canvas + inspector)
|
||||||
|
// takes the entire content area, so we strip the standard `.content-inner`
|
||||||
|
// max-width wrapper and pin a block container that `.flow` (display:flex,
|
||||||
|
// height:100%) fills naturally.
|
||||||
|
//
|
||||||
|
// Don't make this wrapper `display: flex` — the child `.flow` would then
|
||||||
|
// be a non-flex flex-item that shrinks to its fixed-width palette +
|
||||||
|
// inspector and leaves the canvas at width 0.
|
||||||
|
export default function WorkflowsLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getPortalSession } from "@/lib/get-session";
|
||||||
|
import { hasOrgRole } from "@/lib/session";
|
||||||
|
import { loadTenantForShell } from "@/lib/portal-data";
|
||||||
|
import { NotAllowed } from "@/components/portal/NotAllowed";
|
||||||
|
import { WorkflowEditor } from "@/components/portal/workflows/WorkflowEditor";
|
||||||
|
|
||||||
|
export default async function WorkflowsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const session = await getPortalSession();
|
||||||
|
|
||||||
|
if (!session) redirect(`/${slug}/dashboard`);
|
||||||
|
if (!hasOrgRole(session, "IT_ADMIN")) {
|
||||||
|
return <NotAllowed need="IT_ADMIN" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await loadTenantForShell(slug);
|
||||||
|
if (!t) return null;
|
||||||
|
|
||||||
|
return <WorkflowEditor frozen={t.status === "frozen"} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { toast } from "./ToastHost";
|
||||||
|
import type { OrgRole } from "@/lib/session";
|
||||||
|
|
||||||
|
const ROLES: OrgRole[] = ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"];
|
||||||
|
|
||||||
|
// Live write affordance on the Team page — proves the MSW pipeline end
|
||||||
|
// to end. Posts to /api/team/invites; MSW intercepts and returns 201 (or
|
||||||
|
// 402 when the tenant is frozen, via the X-BP-Tenant-Status hint header).
|
||||||
|
export function InviteButton({ tenantStatus }: { tenantStatus: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [role, setRole] = useState<OrgRole>("USER");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (busy) return;
|
||||||
|
setOpen(false);
|
||||||
|
setEmail("");
|
||||||
|
setRole("USER");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!email.includes("@")) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/team/invites", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-bp-tenant-status": tenantStatus,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, role }),
|
||||||
|
});
|
||||||
|
const code = res.headers.get("x-bp-status-code") ?? `${res.status}`;
|
||||||
|
if (res.status === 201) {
|
||||||
|
toast({ msg: `Invitation sent to ${email}`, code });
|
||||||
|
close();
|
||||||
|
} else if (res.status === 402) {
|
||||||
|
toast({
|
||||||
|
msg: "Tenant is read-only — invitation blocked",
|
||||||
|
code: "402 · payment required",
|
||||||
|
});
|
||||||
|
} else if (res.status === 410) {
|
||||||
|
toast({ msg: "Tenant archived — invites unavailable", code: "410 · gone" });
|
||||||
|
} else {
|
||||||
|
toast({ msg: `Invite failed`, code });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
msg: "Invite failed — network error",
|
||||||
|
code: e instanceof Error ? e.message : "unknown",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-accent"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus size={13} /> Invite member
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
className="scrim center"
|
||||||
|
onMouseDown={close}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-head">
|
||||||
|
<span className="brand-mark" style={{ width: 22, height: 22, fontSize: 11 }}>
|
||||||
|
B
|
||||||
|
</span>
|
||||||
|
<span className="modal-title">Invite a teammate</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="field" style={{ marginBottom: 14 }}>
|
||||||
|
<label>Work email</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="input mono"
|
||||||
|
placeholder="name@company.eu"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Role</label>
|
||||||
|
<div className="row wrap" style={{ gap: 6 }}>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
className={"btn btn-sm" + (role === r ? " btn-primary" : "")}
|
||||||
|
onClick={() => setRole(r)}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: 11.5, marginTop: 14, lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
An OIDC invitation will be issued via Keycloak. The user joins
|
||||||
|
on first SSO sign-in.{" "}
|
||||||
|
<span className="mono">POST /api/team/invites</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-foot">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-accent"
|
||||||
|
disabled={busy || !email.includes("@")}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{busy ? "Sending…" : "Send invitation"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Boots the MSW service worker on the client when dev-fixture mode is on.
|
||||||
|
// Reads the marker that `[slug]/layout` injects (window.__BP_MOCK_API__).
|
||||||
|
// Idempotent — calling start() twice is safe because msw bails out on the
|
||||||
|
// second invocation.
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__BP_MOCK_API__?: boolean;
|
||||||
|
__BP_TENANT_STATUS__?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockWorker() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!window.__BP_MOCK_API__) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { worker } = await import("@/mocks/browser");
|
||||||
|
if (cancelled) return;
|
||||||
|
await worker.start({
|
||||||
|
onUnhandledRequest: "bypass",
|
||||||
|
quiet: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[mock-worker] failed to start:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,24 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { Sun, Moon } from "lucide-react";
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
function readTheme(): Theme {
|
function getThemeFromDom(): Theme {
|
||||||
if (typeof document === "undefined") return "light";
|
|
||||||
const attr = document.documentElement.getAttribute("data-theme");
|
const attr = document.documentElement.getAttribute("data-theme");
|
||||||
return attr === "dark" ? "dark" : "light";
|
return attr === "dark" ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeToggle() {
|
// SSR snapshot — must be a stable reference per React's docs. The root
|
||||||
const [theme, setTheme] = useState<Theme>("light");
|
// layout always renders `data-theme="light"` on the server, then a head
|
||||||
const [mounted, setMounted] = useState(false);
|
// script overrides to the user's preference before hydration. `<html>`
|
||||||
|
// has `suppressHydrationWarning` so the mismatch is intentional.
|
||||||
|
function getServerSnapshot(): Theme {
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
function subscribe(onChange: () => void): () => void {
|
||||||
setTheme(readTheme());
|
const target = document.documentElement;
|
||||||
setMounted(true);
|
const observer = new MutationObserver(onChange);
|
||||||
}, []);
|
observer.observe(target, { attributes: true, attributeFilter: ["data-theme"] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
// useSyncExternalStore is the idiomatic way to read DOM-driven state
|
||||||
|
// into a React component without tripping the "no setState in effect"
|
||||||
|
// rule. The MutationObserver in `subscribe` keeps us in sync when any
|
||||||
|
// other code path (e.g. system preference handler) flips the attribute.
|
||||||
|
const theme = useSyncExternalStore(subscribe, getThemeFromDom, getServerSnapshot);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
const next: Theme = theme === "dark" ? "light" : "dark";
|
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||||
@@ -28,7 +40,6 @@ export function ThemeToggle() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* no-op */
|
/* no-op */
|
||||||
}
|
}
|
||||||
setTheme(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,9 +48,9 @@ export function ThemeToggle() {
|
|||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
title={mounted ? `Switch to ${theme === "dark" ? "light" : "dark"} theme` : "Toggle theme"}
|
title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
|
||||||
>
|
>
|
||||||
{mounted && theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
|
{theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type ToastEvent = {
|
||||||
|
msg: string;
|
||||||
|
code?: string;
|
||||||
|
/** Override default 3.4s auto-dismiss. */
|
||||||
|
ttlMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastItem = ToastEvent & { id: number };
|
||||||
|
|
||||||
|
const CHANNEL = "bp.toast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a toast from anywhere on the client:
|
||||||
|
* import { toast } from "@/components/portal/ToastHost";
|
||||||
|
* toast({ msg: "Invitation sent", code: "201 · invite.created" });
|
||||||
|
*
|
||||||
|
* Falls back gracefully if `ToastHost` isn't mounted (e.g. on the auth
|
||||||
|
* picker) — the event simply has no listener.
|
||||||
|
*/
|
||||||
|
export function toast(t: ToastEvent) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANNEL, { detail: t }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom-right toast queue. One instance, mounted in `[slug]/layout`.
|
||||||
|
export function ToastHost() {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<ToastEvent>).detail;
|
||||||
|
if (!detail) return;
|
||||||
|
const id = Date.now() + Math.floor(Math.random() * 10_000);
|
||||||
|
setItems((xs) => [...xs, { ...detail, id }]);
|
||||||
|
window.setTimeout(
|
||||||
|
() => setItems((xs) => xs.filter((x) => x.id !== id)),
|
||||||
|
detail.ttlMs ?? 3400,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener(CHANNEL, handler as EventListener);
|
||||||
|
return () => window.removeEventListener(CHANNEL, handler as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toasts" aria-live="polite">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.id} className="toast" role="status">
|
||||||
|
<div className="col" style={{ gap: 2 }}>
|
||||||
|
<span>{t.msg}</span>
|
||||||
|
{t.code ? <span className="t-code">{t.code}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,825 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Maximize2,
|
||||||
|
Minus,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
defConfig,
|
||||||
|
FLOW_CATS,
|
||||||
|
FLOW_MODULES,
|
||||||
|
KIND_COLOR,
|
||||||
|
NODE_W,
|
||||||
|
modsByCat,
|
||||||
|
nodeH,
|
||||||
|
portX,
|
||||||
|
portY,
|
||||||
|
seedFlow,
|
||||||
|
wirePath,
|
||||||
|
type FlowEdge,
|
||||||
|
type FlowKind,
|
||||||
|
type FlowNode,
|
||||||
|
type FlowSetting,
|
||||||
|
} from "@/lib/flow-modules";
|
||||||
|
|
||||||
|
type Selection =
|
||||||
|
| { type: "node"; id: string }
|
||||||
|
| { type: "edge"; id: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
type DragState =
|
||||||
|
| { mode: "pan"; sx: number; sy: number; px: number; py: number }
|
||||||
|
| { mode: "move"; id: string; ox: number; oy: number }
|
||||||
|
| { mode: "wire"; from: [string, number] }
|
||||||
|
| { mode: "new"; mod: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
type PendingWire = {
|
||||||
|
from: [string, number];
|
||||||
|
x0: number;
|
||||||
|
y0: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Ghost = { x: number; y: number; mod: string };
|
||||||
|
|
||||||
|
type Toast = { id: number; msg: string; code?: string };
|
||||||
|
|
||||||
|
const SEED = seedFlow();
|
||||||
|
|
||||||
|
export function WorkflowEditor({ frozen }: { frozen: boolean }) {
|
||||||
|
const [nodes, setNodes] = useState<FlowNode[]>(SEED.nodes);
|
||||||
|
const [edges, setEdges] = useState<FlowEdge[]>(SEED.edges);
|
||||||
|
const [sel, setSel] = useState<Selection>({ type: "node", id: "n2" });
|
||||||
|
const [pan, setPan] = useState({ x: 22, y: 54 });
|
||||||
|
const [zoom, setZoom] = useState(0.78);
|
||||||
|
const [pending, setPending] = useState<PendingWire | null>(null);
|
||||||
|
const [ghost, setGhost] = useState<Ghost | null>(null);
|
||||||
|
const [active, setActive] = useState<string | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [name, setName] = useState("Findings → evidence + notify");
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dragRef = useRef<DragState>(null);
|
||||||
|
// Latest pan/zoom mirrored into a ref so the global mousemove handler
|
||||||
|
// (registered once in the effect below) can read the current viewport
|
||||||
|
// without re-subscribing on every change. The mirror is updated in an
|
||||||
|
// effect rather than during render to satisfy React's "no ref access
|
||||||
|
// during render" rule.
|
||||||
|
const stateRef = useRef({ pan, zoom });
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = { pan, zoom };
|
||||||
|
}, [pan, zoom]);
|
||||||
|
|
||||||
|
const toWorld = useCallback((cx: number, cy: number) => {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap) return { x: 0, y: 0 };
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
const { pan, zoom } = stateRef.current;
|
||||||
|
return { x: (cx - r.left - pan.x) / zoom, y: (cy - r.top - pan.y) / zoom };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toast = useCallback((msg: string, code?: string) => {
|
||||||
|
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||||
|
setToasts((ts) => [...ts, { id, msg, code }]);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setToasts((ts) => ts.filter((t) => t.id !== id));
|
||||||
|
}, 3400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global mouse handlers for drag-pan, node-move, wire-draw, palette-ghost.
|
||||||
|
useEffect(() => {
|
||||||
|
const move = (e: MouseEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.mode === "pan") {
|
||||||
|
setPan({ x: d.px + (e.clientX - d.sx), y: d.py + (e.clientY - d.sy) });
|
||||||
|
} else if (d.mode === "move") {
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) =>
|
||||||
|
n.id === d.id
|
||||||
|
? { ...n, x: Math.round(w.x - d.ox), y: Math.round(w.y - d.oy) }
|
||||||
|
: n,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (d.mode === "wire") {
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
setPending((p) => (p ? { ...p, x: w.x, y: w.y } : p));
|
||||||
|
} else if (d.mode === "new") {
|
||||||
|
setGhost({ x: e.clientX, y: e.clientY, mod: d.mod });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const up = (e: MouseEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (d && d.mode === "new") {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (wrap) {
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX > r.left &&
|
||||||
|
e.clientX < r.right &&
|
||||||
|
e.clientY > r.top &&
|
||||||
|
e.clientY < r.bottom
|
||||||
|
) {
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
const id = "n" + Date.now().toString(36);
|
||||||
|
const mod = d.mod;
|
||||||
|
setNodes((ns) => [
|
||||||
|
...ns,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
mod,
|
||||||
|
x: Math.round(w.x - NODE_W / 2),
|
||||||
|
y: Math.round(w.y - 28),
|
||||||
|
config: defConfig(mod),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSel({ type: "node", id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGhost(null);
|
||||||
|
}
|
||||||
|
if (d && d.mode === "wire") setPending(null);
|
||||||
|
dragRef.current = null;
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", move);
|
||||||
|
window.addEventListener("mouseup", up);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", move);
|
||||||
|
window.removeEventListener("mouseup", up);
|
||||||
|
};
|
||||||
|
}, [toWorld]);
|
||||||
|
|
||||||
|
const deleteSel = useCallback(() => {
|
||||||
|
setSel((s) => {
|
||||||
|
if (!s) return s;
|
||||||
|
if (s.type === "node") {
|
||||||
|
setNodes((ns) => ns.filter((n) => n.id !== s.id));
|
||||||
|
setEdges((es) => es.filter((e) => e.from[0] !== s.id && e.to[0] !== s.id));
|
||||||
|
} else {
|
||||||
|
setEdges((es) => es.filter((e) => e.id !== s.id));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Delete/Backspace removes the current selection (unless an input has
|
||||||
|
// focus — we don't want to nuke nodes while someone's typing).
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
const tag = (document.activeElement as HTMLElement | null)?.tagName;
|
||||||
|
if (
|
||||||
|
(e.key === "Delete" || e.key === "Backspace") &&
|
||||||
|
sel &&
|
||||||
|
tag !== "INPUT" &&
|
||||||
|
tag !== "TEXTAREA" &&
|
||||||
|
tag !== "SELECT"
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteSel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [sel, deleteSel]);
|
||||||
|
|
||||||
|
const startWire = (e: React.MouseEvent, nodeId: string, outIdx: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const ox = portX(node, "out");
|
||||||
|
const oy = portY(node, "out")[outIdx];
|
||||||
|
dragRef.current = { mode: "wire", from: [nodeId, outIdx] };
|
||||||
|
setPending({ from: [nodeId, outIdx], x0: ox, y0: oy, x: ox, y: oy });
|
||||||
|
};
|
||||||
|
|
||||||
|
const endWire = (e: React.MouseEvent, nodeId: string, inIdx: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (d && d.mode === "wire") {
|
||||||
|
const from = d.from;
|
||||||
|
if (from[0] !== nodeId) {
|
||||||
|
setEdges((es) => [
|
||||||
|
...es.filter((ed) => !(ed.to[0] === nodeId && ed.to[1] === inIdx)),
|
||||||
|
{ id: "e" + Date.now().toString(36), from, to: [nodeId, inIdx] },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
dragRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testRun = () => {
|
||||||
|
if (frozen) {
|
||||||
|
toast("Tenant frozen — re-activate to run", "402 → reactivation.requested");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRunning(true);
|
||||||
|
const incoming: Record<string, number> = {};
|
||||||
|
edges.forEach((e) => {
|
||||||
|
incoming[e.to[0]] = (incoming[e.to[0]] || 0) + 1;
|
||||||
|
});
|
||||||
|
const order: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let frontier = nodes.filter((n) => !incoming[n.id]).map((n) => n.id);
|
||||||
|
while (frontier.length) {
|
||||||
|
const next: string[] = [];
|
||||||
|
frontier.forEach((id) => {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
order.push(id);
|
||||||
|
edges.filter((e) => e.from[0] === id).forEach((e) => next.push(e.to[0]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (!seen.has(n.id)) order.push(n.id);
|
||||||
|
});
|
||||||
|
order.forEach((id, i) => window.setTimeout(() => setActive(id), i * 420));
|
||||||
|
window.setTimeout(
|
||||||
|
() => {
|
||||||
|
setRunning(false);
|
||||||
|
setActive(null);
|
||||||
|
toast(
|
||||||
|
`Test run complete · ${nodes.length} nodes · 0 errors`,
|
||||||
|
"workflow.tested",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
order.length * 420 + 700,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selNode = sel?.type === "node" ? nodes.find((n) => n.id === sel.id) ?? null : null;
|
||||||
|
const selMod = selNode ? FLOW_MODULES[selNode.mod] : null;
|
||||||
|
|
||||||
|
const updateConfig = (k: string, v: string | number | boolean) => {
|
||||||
|
if (!selNode) return;
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) =>
|
||||||
|
n.id === selNode.id ? { ...n, config: { ...n.config, [k]: v } } : n,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const grid: CSSProperties = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundPosition: `${pan.x}px ${pan.y}px`,
|
||||||
|
backgroundSize: `${22 * zoom}px ${22 * zoom}px`,
|
||||||
|
}),
|
||||||
|
[pan.x, pan.y, zoom],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow">
|
||||||
|
{/* ---- palette ---- */}
|
||||||
|
<aside className="flow-palette">
|
||||||
|
<div className="flow-pal-head">
|
||||||
|
<span className="eyebrow">MODULE LIBRARY</span>
|
||||||
|
<span className="muted mono" style={{ fontSize: 9.5 }}>
|
||||||
|
drag onto canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flow-pal-body">
|
||||||
|
{FLOW_CATS.map((cat) => {
|
||||||
|
const open = !collapsed[cat.id];
|
||||||
|
const items = modsByCat(cat.id);
|
||||||
|
return (
|
||||||
|
<div className="ptree-group" key={cat.id}>
|
||||||
|
<div
|
||||||
|
className="ptree-title"
|
||||||
|
onClick={() =>
|
||||||
|
setCollapsed((c) => ({ ...c, [cat.id]: !c[cat.id] }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown size={12} style={{ color: "var(--ink-3)" }} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={12} style={{ color: "var(--ink-3)" }} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="dot"
|
||||||
|
style={{ background: KIND_COLOR[cat.kind] }}
|
||||||
|
/>
|
||||||
|
<span>{cat.label}</span>
|
||||||
|
<span className="ptree-count">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
{open
|
||||||
|
? items.map((m) => (
|
||||||
|
<div
|
||||||
|
className="pitem"
|
||||||
|
key={m.id}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragRef.current = { mode: "new", mod: m.id };
|
||||||
|
setGhost({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
mod: m.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pitem-mono"
|
||||||
|
style={{ color: KIND_COLOR[m.kind as FlowKind] }}
|
||||||
|
>
|
||||||
|
{m.mono}
|
||||||
|
</span>
|
||||||
|
<span className="pitem-name">{m.name}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ---- canvas ---- */}
|
||||||
|
<div
|
||||||
|
className="flow-canvas-wrap"
|
||||||
|
ref={wrapRef}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
t === e.currentTarget ||
|
||||||
|
t.classList.contains("flow-grid") ||
|
||||||
|
t.classList.contains("flow-layer") ||
|
||||||
|
t.tagName.toLowerCase() === "svg"
|
||||||
|
) {
|
||||||
|
setSel(null);
|
||||||
|
dragRef.current = {
|
||||||
|
mode: "pan",
|
||||||
|
sx: e.clientX,
|
||||||
|
sy: e.clientY,
|
||||||
|
px: pan.x,
|
||||||
|
py: pan.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flow-grid" style={grid} />
|
||||||
|
|
||||||
|
{/* toolbar */}
|
||||||
|
<div className="flow-toolbar">
|
||||||
|
<div className="ft-name">
|
||||||
|
<span
|
||||||
|
className="dot"
|
||||||
|
style={{ background: running ? "var(--accent)" : "var(--ok)" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="ft-input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="ft-meta mono">
|
||||||
|
{nodes.length} nodes · {edges.length} links
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 7 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() =>
|
||||||
|
toast(
|
||||||
|
"Workflow validated · no cycles · all inputs satisfied",
|
||||||
|
"workflow.valid",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check size={14} /> Validate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"btn btn-sm btn-ghost" + (frozen ? " is-disabled" : "")}
|
||||||
|
onClick={() =>
|
||||||
|
frozen
|
||||||
|
? toast("Tenant frozen — writes blocked", "402 → reactivation.requested")
|
||||||
|
: toast(
|
||||||
|
`Workflow saved · v12 · ${nodes.length} nodes`,
|
||||||
|
"workflow.saved",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Save size={14} /> Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={testRun}
|
||||||
|
>
|
||||||
|
<Play size={14} /> {running ? "Running…" : "Test run"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* zoom controls */}
|
||||||
|
<div className="flow-zoom">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoom((z) => Math.min(1.6, +(z + 0.15).toFixed(2)))}
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="mono">{Math.round(zoom * 100)}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoom((z) => Math.max(0.5, +(z - 0.15).toFixed(2)))}
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setZoom(0.78);
|
||||||
|
setPan({ x: 22, y: 54 });
|
||||||
|
}}
|
||||||
|
title="Reset view"
|
||||||
|
aria-label="Reset view"
|
||||||
|
>
|
||||||
|
<Maximize2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flow-layer"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* wires */}
|
||||||
|
<svg
|
||||||
|
className="flow-wires"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
overflow: "visible",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{edges.map((e) => {
|
||||||
|
const a = nodes.find((n) => n.id === e.from[0]);
|
||||||
|
const b = nodes.find((n) => n.id === e.to[0]);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
const x1 = portX(a, "out");
|
||||||
|
const y1 = portY(a, "out")[e.from[1]];
|
||||||
|
const x2 = portX(b, "in");
|
||||||
|
const y2 = portY(b, "in")[e.to[1]];
|
||||||
|
const lit = running && active === e.from[0];
|
||||||
|
const selected =
|
||||||
|
sel?.type === "edge" && sel.id === e.id;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={e.id}
|
||||||
|
className={
|
||||||
|
"wire" +
|
||||||
|
(selected ? " sel" : "") +
|
||||||
|
(lit ? " run" : "")
|
||||||
|
}
|
||||||
|
d={wirePath(x1, y1, x2, y2)}
|
||||||
|
onMouseDown={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setSel({ type: "edge", id: e.id });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pending ? (
|
||||||
|
<path
|
||||||
|
className="wire pending"
|
||||||
|
d={wirePath(pending.x0, pending.y0, pending.x, pending.y)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* nodes */}
|
||||||
|
{nodes.map((n) => {
|
||||||
|
const m = FLOW_MODULES[n.mod];
|
||||||
|
if (!m) return null;
|
||||||
|
const H = nodeH(m);
|
||||||
|
const insY = portY(n, "in");
|
||||||
|
const outsY = portY(n, "out");
|
||||||
|
const isSel = sel?.type === "node" && sel.id === n.id;
|
||||||
|
const firstSetting = m.settings?.[0];
|
||||||
|
const firstValue =
|
||||||
|
firstSetting && n.config[firstSetting.k] != null
|
||||||
|
? String(n.config[firstSetting.k])
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className={
|
||||||
|
"fnode" +
|
||||||
|
(isSel ? " sel" : "") +
|
||||||
|
(active === n.id ? " active" : "")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
left: n.x,
|
||||||
|
top: n.y,
|
||||||
|
width: NODE_W,
|
||||||
|
minHeight: H,
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSel({ type: "node", id: n.id });
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
dragRef.current = {
|
||||||
|
mode: "move",
|
||||||
|
id: n.id,
|
||||||
|
ox: w.x - n.x,
|
||||||
|
oy: w.y - n.y,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="fnode-head">
|
||||||
|
<span
|
||||||
|
className="fnode-mono"
|
||||||
|
style={{ borderColor: KIND_COLOR[m.kind] }}
|
||||||
|
>
|
||||||
|
{m.mono}
|
||||||
|
</span>
|
||||||
|
<span className="fnode-title">{m.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="fnode-body">
|
||||||
|
{firstValue.length ? firstValue : m.desc}
|
||||||
|
</div>
|
||||||
|
{m.in.map((lbl, i) => (
|
||||||
|
<div
|
||||||
|
key={"i" + i}
|
||||||
|
className="fport in"
|
||||||
|
style={{ top: insY[i] - n.y - 5.5 }}
|
||||||
|
onMouseUp={(e) => endWire(e, n.id, i)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{m.in.length > 1 ? (
|
||||||
|
<span className="fport-lbl in">{lbl}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{m.out.map((lbl, i) => (
|
||||||
|
<div
|
||||||
|
key={"o" + i}
|
||||||
|
className={
|
||||||
|
"fport out" +
|
||||||
|
(lbl === "fail" || lbl === "false" ? " neg" : "")
|
||||||
|
}
|
||||||
|
style={{ top: outsY[i] - n.y - 5.5 }}
|
||||||
|
onMouseDown={(e) => startWire(e, n.id, i)}
|
||||||
|
>
|
||||||
|
{m.out.length > 1 ? (
|
||||||
|
<span className="fport-lbl out">{lbl}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- inspector ---- */}
|
||||||
|
<aside className="flow-inspector">
|
||||||
|
{selNode && selMod ? (
|
||||||
|
<>
|
||||||
|
<div className="flow-insp-head">
|
||||||
|
<span
|
||||||
|
className="fnode-mono"
|
||||||
|
style={{ borderColor: KIND_COLOR[selMod.kind] }}
|
||||||
|
>
|
||||||
|
{selMod.mono}
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="fi-title">{selMod.name}</div>
|
||||||
|
<div className="mono muted" style={{ fontSize: 9.5 }}>
|
||||||
|
{selNode.id} · {selMod.cat}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flow-insp-body">
|
||||||
|
<div className="eyebrow" style={{ marginBottom: 10 }}>
|
||||||
|
SETTINGS
|
||||||
|
</div>
|
||||||
|
<div className="col" style={{ gap: 12 }}>
|
||||||
|
{(selMod.settings || []).map((s) => (
|
||||||
|
<FlowField
|
||||||
|
key={s.k}
|
||||||
|
s={s}
|
||||||
|
value={selNode.config[s.k]}
|
||||||
|
onChange={(v) => updateConfig(s.k, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!selMod.settings?.length ? (
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
No settings for this module.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="divider" style={{ margin: "16px 0" }} />
|
||||||
|
<dl
|
||||||
|
className="dl"
|
||||||
|
style={{ gridTemplateColumns: "max-content 1fr", gap: "7px 14px" }}
|
||||||
|
>
|
||||||
|
<dt>Inputs</dt>
|
||||||
|
<dd className="mono">{selMod.in.length || "—"}</dd>
|
||||||
|
<dt>Outputs</dt>
|
||||||
|
<dd className="mono">{selMod.out.join(", ") || "terminal"}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="flow-insp-foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost btn-danger"
|
||||||
|
onClick={deleteSel}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Remove node
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : sel?.type === "edge" ? (
|
||||||
|
<div className="flow-insp-empty">
|
||||||
|
<div className="eyebrow" style={{ marginBottom: 8 }}>
|
||||||
|
CONNECTION
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ fontSize: 12.5 }}>
|
||||||
|
A data link between two modules. The upstream module's output
|
||||||
|
is passed to the downstream input.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost btn-danger"
|
||||||
|
style={{ marginTop: 14 }}
|
||||||
|
onClick={deleteSel}
|
||||||
|
>
|
||||||
|
<X size={13} /> Delete link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flow-insp-empty">
|
||||||
|
<div className="eyebrow" style={{ marginBottom: 8 }}>
|
||||||
|
INSPECTOR
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: 12.5, lineHeight: 1.55 }}
|
||||||
|
>
|
||||||
|
Select a node to configure it, drag a module from the library to
|
||||||
|
add one, or drag from an output port to wire modules together.
|
||||||
|
</p>
|
||||||
|
<div className="flow-legend">
|
||||||
|
{FLOW_CATS.map((c) => (
|
||||||
|
<span key={c.id} className="fl">
|
||||||
|
<span
|
||||||
|
className="dot"
|
||||||
|
style={{ background: KIND_COLOR[c.kind] }}
|
||||||
|
/>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{ghost ? (
|
||||||
|
<div
|
||||||
|
className="flow-ghost"
|
||||||
|
style={{ left: ghost.x, top: ghost.y }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="fnode-mono"
|
||||||
|
style={{
|
||||||
|
borderColor: KIND_COLOR[FLOW_MODULES[ghost.mod]?.kind ?? "trigger"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FLOW_MODULES[ghost.mod]?.mono}
|
||||||
|
</span>
|
||||||
|
{FLOW_MODULES[ghost.mod]?.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="toasts">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} className="toast">
|
||||||
|
<div className="col" style={{ gap: 2 }}>
|
||||||
|
<span>{t.msg}</span>
|
||||||
|
{t.code ? <span className="t-code">{t.code}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One inspector field per setting. Type-discriminated so each branch
|
||||||
|
// hands `onChange` a concrete value type.
|
||||||
|
function FlowField({
|
||||||
|
s,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
s: FlowSetting;
|
||||||
|
value: string | number | boolean | undefined;
|
||||||
|
onChange: (v: string | number | boolean) => void;
|
||||||
|
}) {
|
||||||
|
if (s.type === "toggle") {
|
||||||
|
const on = Boolean(value);
|
||||||
|
return (
|
||||||
|
<div className="row between" style={{ padding: "2px 0" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"fswitch" + (on ? " on" : "")}
|
||||||
|
onClick={() => onChange(!on)}
|
||||||
|
aria-pressed={on}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s.type === "select") {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{s.label}</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{s.opts.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s.type === "area") {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{s.label}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={2}
|
||||||
|
style={{ resize: "vertical", fontSize: 12 }}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
placeholder={s.ph}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// text / num
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{s.label}</label>
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type={s.type === "num" ? "number" : "text"}
|
||||||
|
step="0.05"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
placeholder={s.ph}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(s.type === "num" ? parseFloat(e.target.value) : e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// Module catalog for the Workflows editor — TS port of FLOW_MODULES /
|
||||||
|
// FLOW_CATS from the design handoff's `screens_flow.jsx`. The shapes here
|
||||||
|
// drive the palette tree, the per-node ports, and the inspector form.
|
||||||
|
|
||||||
|
export type FlowKind = "trigger" | "scanner" | "ai" | "logic" | "action";
|
||||||
|
|
||||||
|
export const KIND_COLOR: Record<FlowKind, string> = {
|
||||||
|
trigger: "var(--accent)",
|
||||||
|
scanner: "var(--ink-2)",
|
||||||
|
ai: "var(--ink-2)",
|
||||||
|
logic: "var(--warn)",
|
||||||
|
action: "var(--ok)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlowCat = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: FlowKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLOW_CATS: FlowCat[] = [
|
||||||
|
{ id: "triggers", label: "Triggers", kind: "trigger" },
|
||||||
|
{ id: "scanner", label: "Compliance Scanner", kind: "scanner" },
|
||||||
|
{ id: "certifai", label: "CERTifAI", kind: "ai" },
|
||||||
|
{ id: "logic", label: "Logic", kind: "logic" },
|
||||||
|
{ id: "actions", label: "Actions", kind: "action" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type FlowSetting =
|
||||||
|
| { k: string; label: string; type: "select"; opts: string[]; def: string; ph?: string }
|
||||||
|
| { k: string; label: string; type: "text"; def: string; ph?: string }
|
||||||
|
| { k: string; label: string; type: "num"; def: number; ph?: string }
|
||||||
|
| { k: string; label: string; type: "area"; def: string; ph?: string }
|
||||||
|
| { k: string; label: string; type: "toggle"; def: boolean; ph?: string };
|
||||||
|
|
||||||
|
export type FlowModule = {
|
||||||
|
name: string;
|
||||||
|
cat: string;
|
||||||
|
kind: FlowKind;
|
||||||
|
mono: string;
|
||||||
|
in: string[];
|
||||||
|
out: string[];
|
||||||
|
desc: string;
|
||||||
|
settings?: FlowSetting[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLOW_MODULES: Record<string, FlowModule> = {
|
||||||
|
// ---- triggers ----
|
||||||
|
schedule: {
|
||||||
|
name: "On schedule",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "⏱",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "daily · 02:00",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "cadence",
|
||||||
|
label: "Cadence",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Every hour", "Daily", "Weekly", "Monthly"],
|
||||||
|
def: "Daily",
|
||||||
|
},
|
||||||
|
{ k: "time", label: "At time (UTC)", type: "text", def: "02:00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"scan-complete": {
|
||||||
|
name: "On scan complete",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "◆",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "compliance-scanner",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "product",
|
||||||
|
label: "Product",
|
||||||
|
type: "select",
|
||||||
|
opts: ["compliance-scanner", "certifai", "any"],
|
||||||
|
def: "compliance-scanner",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"new-finding": {
|
||||||
|
name: "On new finding",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "▲",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "severity ≥ Medium",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "minSev",
|
||||||
|
label: "Min severity",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Low", "Medium", "High", "Critical"],
|
||||||
|
def: "Medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
webhook: {
|
||||||
|
name: "Webhook",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "↯",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "POST /hooks/…",
|
||||||
|
settings: [
|
||||||
|
{ k: "path", label: "Path", type: "text", def: "/hooks/ingest" },
|
||||||
|
{ k: "secret", label: "Signing secret", type: "text", def: "whsec_••••" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- scanner ----
|
||||||
|
"run-scan": {
|
||||||
|
name: "Run scan",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "CS",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "full · cloud + code",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "scope",
|
||||||
|
label: "Scope",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Full", "Cloud only", "Code only", "Delta"],
|
||||||
|
def: "Full",
|
||||||
|
},
|
||||||
|
{ k: "frameworks", label: "Frameworks", type: "text", def: "ISO 27001, BSI C5" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
name: "Filter findings",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "≡",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "status = open",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "status",
|
||||||
|
label: "Status",
|
||||||
|
type: "select",
|
||||||
|
opts: ["open", "resolved", "any"],
|
||||||
|
def: "open",
|
||||||
|
},
|
||||||
|
{ k: "control", label: "Control matches", type: "text", def: "", ph: "e.g. ISO 27001 A.8*" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sev-gate": {
|
||||||
|
name: "Severity gate",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "⊟",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["pass", "fail"],
|
||||||
|
desc: "≥ High",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "threshold",
|
||||||
|
label: "Threshold",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Low", "Medium", "High", "Critical"],
|
||||||
|
def: "High",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"map-control": {
|
||||||
|
name: "Map to control",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "⌖",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "framework: ISO 27001",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "framework",
|
||||||
|
label: "Framework",
|
||||||
|
type: "select",
|
||||||
|
opts: ["ISO 27001", "BSI C5", "NIS2", "TISAX"],
|
||||||
|
def: "ISO 27001",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- certifai ----
|
||||||
|
"gen-annex": {
|
||||||
|
name: "Generate Annex IV",
|
||||||
|
cat: "certifai",
|
||||||
|
kind: "ai",
|
||||||
|
mono: "Ai",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "EU AI Act dossier",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "model",
|
||||||
|
label: "Model system",
|
||||||
|
type: "select",
|
||||||
|
opts: ["risk-scorer-v3", "doc-classifier", "all"],
|
||||||
|
def: "risk-scorer-v3",
|
||||||
|
},
|
||||||
|
{ k: "sign", label: "Sign dossier", type: "toggle", def: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"bias-check": {
|
||||||
|
name: "Bias evaluation",
|
||||||
|
cat: "certifai",
|
||||||
|
kind: "ai",
|
||||||
|
mono: "Ai",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["pass", "fail"],
|
||||||
|
desc: "fairness ≥ 0.8",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "metric",
|
||||||
|
label: "Metric",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Demographic parity", "Equalised odds"],
|
||||||
|
def: "Demographic parity",
|
||||||
|
},
|
||||||
|
{ k: "min", label: "Min score", type: "num", def: 0.8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"model-audit": {
|
||||||
|
name: "Model card audit",
|
||||||
|
cat: "certifai",
|
||||||
|
kind: "ai",
|
||||||
|
mono: "Ai",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "Annex IV §2",
|
||||||
|
settings: [{ k: "strict", label: "Strict mode", type: "toggle", def: false }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- logic ----
|
||||||
|
branch: {
|
||||||
|
name: "Branch",
|
||||||
|
cat: "logic",
|
||||||
|
kind: "logic",
|
||||||
|
mono: "⑂",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["true", "false"],
|
||||||
|
desc: "if condition",
|
||||||
|
settings: [
|
||||||
|
{ k: "expr", label: "Condition", type: "text", def: "count > 0", ph: "expression" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
merge: {
|
||||||
|
name: "Merge",
|
||||||
|
cat: "logic",
|
||||||
|
kind: "logic",
|
||||||
|
mono: "⑃",
|
||||||
|
in: ["a", "b"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "wait all",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "mode",
|
||||||
|
label: "Mode",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Wait all", "First wins"],
|
||||||
|
def: "Wait all",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
delay: {
|
||||||
|
name: "Delay",
|
||||||
|
cat: "logic",
|
||||||
|
kind: "logic",
|
||||||
|
mono: "◴",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "1 h",
|
||||||
|
settings: [{ k: "amount", label: "Duration", type: "text", def: "1 h" }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- actions ----
|
||||||
|
"create-evidence": {
|
||||||
|
name: "Create evidence",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "▤",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "PDF · signed",
|
||||||
|
settings: [
|
||||||
|
{ k: "name", label: "Bundle name", type: "text", def: "Auto-evidence" },
|
||||||
|
{
|
||||||
|
k: "format",
|
||||||
|
label: "Format",
|
||||||
|
type: "select",
|
||||||
|
opts: ["PDF", "JSON", "PDF + JSON"],
|
||||||
|
def: "PDF",
|
||||||
|
},
|
||||||
|
{ k: "sign", label: "Hash-chain sign", type: "toggle", def: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
notify: {
|
||||||
|
name: "Notify",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "✉",
|
||||||
|
in: ["in"],
|
||||||
|
out: [],
|
||||||
|
desc: "Slack · #compliance",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "channel",
|
||||||
|
label: "Channel",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Slack", "Email", "Microsoft Teams"],
|
||||||
|
def: "Slack",
|
||||||
|
},
|
||||||
|
{ k: "target", label: "Target", type: "text", def: "#compliance" },
|
||||||
|
{ k: "msg", label: "Message", type: "area", def: "{{count}} findings need review" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"open-ticket": {
|
||||||
|
name: "Open ticket",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "⊞",
|
||||||
|
in: ["in"],
|
||||||
|
out: [],
|
||||||
|
desc: "Jira · COMP",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "system",
|
||||||
|
label: "System",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Jira", "ServiceNow", "Linear"],
|
||||||
|
def: "Jira",
|
||||||
|
},
|
||||||
|
{ k: "project", label: "Project key", type: "text", def: "COMP" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"export-bundle": {
|
||||||
|
name: "Export bundle",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "⇪",
|
||||||
|
in: ["in"],
|
||||||
|
out: [],
|
||||||
|
desc: "S3 · eu-central",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "dest",
|
||||||
|
label: "Destination",
|
||||||
|
type: "select",
|
||||||
|
opts: ["S3 (eu-central)", "SFTP", "Download"],
|
||||||
|
def: "S3 (eu-central)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modsByCat = (cat: string) =>
|
||||||
|
Object.entries(FLOW_MODULES)
|
||||||
|
.filter(([, m]) => m.cat === cat)
|
||||||
|
.map(([id, m]) => ({ id, ...m }));
|
||||||
|
|
||||||
|
export const NODE_W = 202;
|
||||||
|
|
||||||
|
export function nodeH(m: FlowModule): number {
|
||||||
|
return Math.max(58, 36 + Math.max(m.in.length, m.out.length, 1) * 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defConfig(modId: string): Record<string, string | number | boolean> {
|
||||||
|
const m = FLOW_MODULES[modId];
|
||||||
|
if (!m) return {};
|
||||||
|
const c: Record<string, string | number | boolean> = {};
|
||||||
|
(m.settings || []).forEach((s) => {
|
||||||
|
c[s.k] = s.def;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowNode = {
|
||||||
|
id: string;
|
||||||
|
mod: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
config: Record<string, string | number | boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlowEdge = {
|
||||||
|
id: string;
|
||||||
|
from: [string, number];
|
||||||
|
to: [string, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function seedFlow(): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
||||||
|
const mk = (id: string, mod: string, x: number, y: number): FlowNode => ({
|
||||||
|
id,
|
||||||
|
mod,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
config: defConfig(mod),
|
||||||
|
});
|
||||||
|
const nodes: FlowNode[] = [
|
||||||
|
mk("n1", "scan-complete", 24, 70),
|
||||||
|
mk("n2", "sev-gate", 270, 86),
|
||||||
|
mk("n3", "create-evidence", 516, 24),
|
||||||
|
mk("n4", "notify", 516, 188),
|
||||||
|
mk("n6", "schedule", 24, 320),
|
||||||
|
mk("n7", "gen-annex", 270, 320),
|
||||||
|
mk("n5", "map-control", 516, 320),
|
||||||
|
];
|
||||||
|
const edges: FlowEdge[] = [
|
||||||
|
{ id: "e1", from: ["n1", 0], to: ["n2", 0] },
|
||||||
|
{ id: "e2", from: ["n2", 0], to: ["n3", 0] },
|
||||||
|
{ id: "e3", from: ["n2", 1], to: ["n4", 0] },
|
||||||
|
{ id: "e4", from: ["n6", 0], to: ["n7", 0] },
|
||||||
|
{ id: "e5", from: ["n7", 0], to: ["n5", 0] },
|
||||||
|
];
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portY(node: FlowNode, side: "in" | "out"): number[] {
|
||||||
|
const m = FLOW_MODULES[node.mod];
|
||||||
|
if (!m) return [];
|
||||||
|
const ports = side === "in" ? m.in : m.out;
|
||||||
|
const H = nodeH(m);
|
||||||
|
if (ports.length === 0) return [];
|
||||||
|
return ports.map((_, i) => node.y + (H * (i + 1)) / (ports.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portX(node: FlowNode, side: "in" | "out"): number {
|
||||||
|
return side === "in" ? node.x : node.x + NODE_W;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wirePath(x1: number, y1: number, x2: number, y2: number): string {
|
||||||
|
const dx = Math.max(36, Math.abs(x2 - x1) * 0.45);
|
||||||
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||||
|
}
|
||||||
+14
-1
@@ -11,7 +11,20 @@ export type HostMatch =
|
|||||||
| { kind: "unknown" };
|
| { kind: "unknown" };
|
||||||
|
|
||||||
// Longest-first so `stage.breakpilot.com` is matched before `breakpilot.com`.
|
// Longest-first so `stage.breakpilot.com` is matched before `breakpilot.com`.
|
||||||
const APEX_HOSTS = ["stage.breakpilot.com", "breakpilot.com", "localhost"];
|
// Built-ins cover dev (localhost) + the canonical breakpilot.com targets.
|
||||||
|
// PORTAL_APEX_HOSTS is a comma-separated env override for per-environment
|
||||||
|
// hosts (e.g. portal-dev.meghsakha.com while breakpilot.com isn't registered).
|
||||||
|
const APEX_HOSTS = (() => {
|
||||||
|
const base = ["stage.breakpilot.com", "breakpilot.com", "localhost"];
|
||||||
|
const extra = (process.env.PORTAL_APEX_HOSTS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((h) => h.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
// Longest-first to keep the suffix-strip loop correct.
|
||||||
|
return Array.from(new Set([...extra, ...base])).sort(
|
||||||
|
(a, b) => b.length - a.length,
|
||||||
|
);
|
||||||
|
})();
|
||||||
const APEX_SET = new Set(APEX_HOSTS);
|
const APEX_SET = new Set(APEX_HOSTS);
|
||||||
|
|
||||||
export function parseHost(host: string | null | undefined): HostMatch {
|
export function parseHost(host: string | null | undefined): HostMatch {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resetServiceTokenCache,
|
||||||
|
serviceAuthHeader,
|
||||||
|
serviceToken,
|
||||||
|
} from "./service-token";
|
||||||
|
|
||||||
|
const ISSUER = "https://auth.breakpilot.com/realms/breakpilot-dev";
|
||||||
|
|
||||||
|
function tokenResponse(value: string, expiresIn = 300) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: value, expires_in: expiresIn }),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configure() {
|
||||||
|
process.env.KEYCLOAK_ISSUER = ISSUER;
|
||||||
|
process.env.PORTAL_SVC_CLIENT_ID = "portal-svc";
|
||||||
|
process.env.PORTAL_SVC_CLIENT_SECRET = "shh";
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetServiceTokenCache();
|
||||||
|
delete process.env.PORTAL_SVC_CLIENT_ID;
|
||||||
|
delete process.env.PORTAL_SVC_CLIENT_SECRET;
|
||||||
|
delete process.env.KEYCLOAK_ISSUER;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serviceToken", () => {
|
||||||
|
test("returns null and makes no request when unconfigured", async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
expect(await serviceToken()).toBeNull();
|
||||||
|
expect(await serviceAuthHeader()).toEqual({});
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requests a client_credentials token against the realm", async () => {
|
||||||
|
configure();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
|
||||||
|
expect(await serviceToken()).toBe("tok-1");
|
||||||
|
|
||||||
|
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe(`${ISSUER}/protocol/openid-connect/token`);
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
const body = new URLSearchParams(init.body as string);
|
||||||
|
expect(body.get("grant_type")).toBe("client_credentials");
|
||||||
|
expect(body.get("client_id")).toBe("portal-svc");
|
||||||
|
expect(body.get("client_secret")).toBe("shh");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caches the token across calls", async () => {
|
||||||
|
configure();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
|
||||||
|
await serviceToken();
|
||||||
|
await serviceToken();
|
||||||
|
await serviceToken();
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("de-dupes concurrent fetches into one request", async () => {
|
||||||
|
configure();
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
|
||||||
|
const results = await Promise.all([
|
||||||
|
serviceToken(),
|
||||||
|
serviceToken(),
|
||||||
|
serviceToken(),
|
||||||
|
]);
|
||||||
|
expect(results).toEqual(["tok-1", "tok-1", "tok-1"]);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshes shortly before expiry", async () => {
|
||||||
|
configure();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-08-25T10:00:00Z"));
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-1", 300))
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-2", 300));
|
||||||
|
|
||||||
|
expect(await serviceToken()).toBe("tok-1");
|
||||||
|
// 4 minutes in: still inside the window
|
||||||
|
vi.setSystemTime(new Date("2026-08-25T10:04:00Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-1");
|
||||||
|
// 4:40 — inside the 30s refresh margin
|
||||||
|
vi.setSystemTime(new Date("2026-08-25T10:04:40Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-2");
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces a rejected token request instead of calling unauthenticated", async () => {
|
||||||
|
configure();
|
||||||
|
// no json() on the mock: fetchToken throws on !ok before reading the body
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(serviceToken()).rejects.toThrow("service token request failed: 401");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces a malformed token response", async () => {
|
||||||
|
configure();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(serviceToken()).rejects.toThrow("no access_token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed fetch does not poison the cache", async () => {
|
||||||
|
configure();
|
||||||
|
vi.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 503 } as Response)
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-ok"));
|
||||||
|
|
||||||
|
await expect(serviceToken()).rejects.toThrow();
|
||||||
|
expect(await serviceToken()).toBe("tok-ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a response without expires_in gets the 300s default lifetime", async () => {
|
||||||
|
configure();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-08-30T10:00:00Z"));
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: "tok-default" }),
|
||||||
|
} as Response)
|
||||||
|
.mockResolvedValueOnce(tokenResponse("tok-next"));
|
||||||
|
|
||||||
|
expect(await serviceToken()).toBe("tok-default");
|
||||||
|
// 4 minutes in: still inside the defaulted 300s window
|
||||||
|
vi.setSystemTime(new Date("2026-08-30T10:04:00Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-default");
|
||||||
|
// past the default expiry
|
||||||
|
vi.setSystemTime(new Date("2026-08-30T10:05:01Z"));
|
||||||
|
expect(await serviceToken()).toBe("tok-next");
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serviceAuthHeader carries the bearer token when configured", async () => {
|
||||||
|
configure();
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(tokenResponse("tok-1"));
|
||||||
|
expect(await serviceAuthHeader()).toEqual({ authorization: "Bearer tok-1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Client-credentials service token for portal → tenant-registry calls.
|
||||||
|
//
|
||||||
|
// tenant-registry's API is INTERNAL_SERVICE_ONLY: once its AUTH_ENABLED
|
||||||
|
// flips, every route except /healthz and /readyz needs a Keycloak token
|
||||||
|
// whose audience contains `tenant-registry`. The portal is a service
|
||||||
|
// principal here — this is machine-to-machine, unrelated to the visitor's
|
||||||
|
// SSO session (that one authenticates a human against `dev-portal`).
|
||||||
|
//
|
||||||
|
// Inert until configured: with no PORTAL_SVC_CLIENT_ID / _SECRET the
|
||||||
|
// helper returns null and callers send no Authorization header, which is
|
||||||
|
// exactly today's behaviour against a tenant-registry that is not yet
|
||||||
|
// enforcing. Configure both to switch the portal over.
|
||||||
|
//
|
||||||
|
// Server-only: the client secret must never reach the browser. Every
|
||||||
|
// caller (src/lib/tenant-registry.ts) already runs server-side.
|
||||||
|
|
||||||
|
const REFRESH_MARGIN_SECONDS = 30;
|
||||||
|
|
||||||
|
type CachedToken = { value: string; expiresAt: number };
|
||||||
|
|
||||||
|
let cached: CachedToken | null = null;
|
||||||
|
// de-dupes concurrent fetches: many parallel renders share one request
|
||||||
|
let inFlight: Promise<CachedToken> | null = null;
|
||||||
|
|
||||||
|
function config(): { issuer: string; clientId: string; secret: string } | null {
|
||||||
|
const clientId = process.env.PORTAL_SVC_CLIENT_ID;
|
||||||
|
const secret = process.env.PORTAL_SVC_CLIENT_SECRET;
|
||||||
|
const issuer = process.env.KEYCLOAK_ISSUER;
|
||||||
|
if (!clientId || !secret || !issuer) return null;
|
||||||
|
return { issuer, clientId, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchToken(cfg: {
|
||||||
|
issuer: string;
|
||||||
|
clientId: string;
|
||||||
|
secret: string;
|
||||||
|
}): Promise<CachedToken> {
|
||||||
|
const res = await fetch(`${cfg.issuer}/protocol/openid-connect/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: cfg.clientId,
|
||||||
|
client_secret: cfg.secret,
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`service token request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
};
|
||||||
|
if (!body.access_token) {
|
||||||
|
throw new Error("service token response carried no access_token");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: body.access_token,
|
||||||
|
expiresAt: Date.now() / 1000 + (body.expires_in ?? 300),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid service token, or null when the portal is not configured to
|
||||||
|
* send one. Cached in memory and refreshed shortly before expiry (realm
|
||||||
|
* tokens live 5 minutes).
|
||||||
|
*/
|
||||||
|
export async function serviceToken(): Promise<string | null> {
|
||||||
|
const cfg = config();
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
if (cached && now < cached.expiresAt - REFRESH_MARGIN_SECONDS) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
if (!inFlight) {
|
||||||
|
inFlight = fetchToken(cfg).finally(() => {
|
||||||
|
inFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cached = await inFlight;
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization header for an outbound call, or {} when unconfigured. */
|
||||||
|
export async function serviceAuthHeader(): Promise<Record<string, string>> {
|
||||||
|
const token = await serviceToken();
|
||||||
|
return token ? { authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: drop the cached token. */
|
||||||
|
export function resetServiceTokenCache(): void {
|
||||||
|
cached = null;
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Tenant Registry client — covers everything the portal needs to call
|
// Tenant Registry client — covers everything the portal needs to call
|
||||||
// from server components and server actions.
|
// from server components and server actions.
|
||||||
|
|
||||||
|
import { serviceAuthHeader } from "./service-token";
|
||||||
|
|
||||||
export type Tenant = {
|
export type Tenant = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -74,9 +76,12 @@ async function req<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<{ status: number; data: T | null }> {
|
): Promise<{ status: number; data: T | null }> {
|
||||||
|
// tenant-registry is INTERNAL_SERVICE_ONLY — the portal calls it as a
|
||||||
|
// service principal. Unconfigured ⇒ {} ⇒ unchanged, header-less calls.
|
||||||
|
const auth = await serviceAuthHeader();
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: { accept: "application/json" },
|
headers: { accept: "application/json", ...auth },
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
};
|
};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
|
|||||||
+4
-2
@@ -31,6 +31,8 @@ export function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Skip Next internals + API + static assets so middleware doesn't
|
// Skip Next internals + API + static assets so middleware doesn't
|
||||||
// double-rewrite the auth callback or _next/static.
|
// double-rewrite the auth callback, _next/static, or the MSW worker.
|
||||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
matcher: [
|
||||||
|
"/((?!api|_next/static|_next/image|favicon.ico|mockServiceWorker.js).*)",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Browser-side MSW worker setup. Imported only by the dev MockWorker
|
||||||
|
// client component — must never run on the server.
|
||||||
|
|
||||||
|
import { setupWorker } from "msw/browser";
|
||||||
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
|
export const worker = setupWorker(...handlers);
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Browser-side mock API for dev-fixture mode.
|
||||||
|
//
|
||||||
|
// Wired into the page via `src/mocks/MockWorker.tsx`. Only initialised when
|
||||||
|
// `BP_DEV_FIXTURE` is set on the server (the env value is forwarded to the
|
||||||
|
// client via a window global). Production builds never start the worker.
|
||||||
|
//
|
||||||
|
// Today's surface is the small set of write paths the design shows. They
|
||||||
|
// don't persist — every response is synthesised so the same click always
|
||||||
|
// looks the same. When the real platform endpoints exist, drop the
|
||||||
|
// matching handler from this file.
|
||||||
|
|
||||||
|
import { http, HttpResponse, delay } from "msw";
|
||||||
|
|
||||||
|
type InvitePayload = {
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestRunPayload = {
|
||||||
|
workflowId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Frozen-tenant guard --------------------------------------------------
|
||||||
|
// In dev-fixture mode the tenant status is encoded in a cookie or a
|
||||||
|
// window global; for now we read a hint from a custom header that the
|
||||||
|
// caller sets, so the same mock handler can respond 402 or 201 depending
|
||||||
|
// on which fixture is currently active.
|
||||||
|
function isFrozen(req: Request): boolean {
|
||||||
|
return req.headers.get("x-bp-tenant-status") === "frozen";
|
||||||
|
}
|
||||||
|
function isArchived(req: Request): boolean {
|
||||||
|
return req.headers.get("x-bp-tenant-status") === "archived";
|
||||||
|
}
|
||||||
|
|
||||||
|
function archivedResponse() {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ error: "tenant_archived", message: "Tenant retention window closed." },
|
||||||
|
{ status: 410 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function frozenResponse() {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
error: "tenant_frozen",
|
||||||
|
message: "Tenant is read-only. Re-activate to resume writes.",
|
||||||
|
},
|
||||||
|
{ status: 402 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
// ---- /api/team/invites -------------------------------------------------
|
||||||
|
http.post("/api/team/invites", async ({ request }) => {
|
||||||
|
if (isArchived(request)) return archivedResponse();
|
||||||
|
if (isFrozen(request)) return frozenResponse();
|
||||||
|
await delay(280);
|
||||||
|
const body = (await request.json().catch(() => ({}))) as InvitePayload;
|
||||||
|
if (!body.email || !body.email.includes("@")) {
|
||||||
|
return HttpResponse.json({ error: "invalid_email" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const role = body.role ?? "USER";
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: "inv-" + Math.random().toString(36).slice(2, 9),
|
||||||
|
email: body.email,
|
||||||
|
role,
|
||||||
|
status: "invited",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 201, headers: { "x-bp-status-code": "201 · invite.created" } },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- /api/scans -------------------------------------------------------
|
||||||
|
http.post("/api/scans", async ({ request }) => {
|
||||||
|
if (isArchived(request)) return archivedResponse();
|
||||||
|
if (isFrozen(request)) return frozenResponse();
|
||||||
|
await delay(420);
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: "scan-" + Math.random().toString(36).slice(2, 9),
|
||||||
|
status: "queued",
|
||||||
|
queued_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: 202, headers: { "x-bp-status-code": "202 · scan.queued" } },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- /api/workflows/:id/test -----------------------------------------
|
||||||
|
http.post("/api/workflows/:id/test", async ({ request, params }) => {
|
||||||
|
if (isArchived(request)) return archivedResponse();
|
||||||
|
if (isFrozen(request)) return frozenResponse();
|
||||||
|
await delay(180);
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
workflow_id: params.id,
|
||||||
|
run_id: "wfr-" + Math.random().toString(36).slice(2, 9),
|
||||||
|
status: "started",
|
||||||
|
} satisfies Record<string, unknown> & { workflow_id: unknown },
|
||||||
|
{ status: 202, headers: { "x-bp-status-code": "202 · workflow.test" } },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- /api/billing/reactivate ----------------------------------------
|
||||||
|
http.post("/api/billing/reactivate", async ({ request }) => {
|
||||||
|
if (isArchived(request)) return archivedResponse();
|
||||||
|
await delay(320);
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ status: "pending", contact: "billing@breakpilot.eu" },
|
||||||
|
{
|
||||||
|
status: 202,
|
||||||
|
headers: { "x-bp-status-code": "202 · reactivation.requested" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Silence unused-type warnings for payloads we don't fully validate.
|
||||||
|
export type { InvitePayload, TestRunPayload };
|
||||||
@@ -9,6 +9,17 @@ export default defineConfig({
|
|||||||
// Skeleton-mode: only enforce coverage on the tested module (src/lib).
|
// Skeleton-mode: only enforce coverage on the tested module (src/lib).
|
||||||
// Re-include the rest of src/ once real code + real tests land.
|
// Re-include the rest of src/ once real code + real tests land.
|
||||||
include: ["src/lib/**/*.ts"],
|
include: ["src/lib/**/*.ts"],
|
||||||
|
// M10.2 design-fixture modules — these are the bridge between the
|
||||||
|
// handoff prototype and the real platform stack. They get replaced
|
||||||
|
// (or thinned out) when tenant-registry carries the design fields
|
||||||
|
// end-to-end; covering them now would mostly assert their literal
|
||||||
|
// structure. Re-add coverage when they stop being fixture glue.
|
||||||
|
exclude: [
|
||||||
|
"src/lib/fixtures.ts",
|
||||||
|
"src/lib/flow-modules.ts",
|
||||||
|
"src/lib/get-session.ts",
|
||||||
|
"src/lib/portal-data.ts",
|
||||||
|
],
|
||||||
reporter: ["text", "json-summary"],
|
reporter: ["text", "json-summary"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 100,
|
lines: 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user