Compare commits

...

63 Commits

Author SHA1 Message Date
miloschwartz
40f49bf6da add pangolin cloud link 2026-02-11 19:46:19 -08:00
Owen
0bfce87dc6 Ignore migrations 2026-02-11 19:25:00 -08:00
Owen
2a0655e9de Bump version 2026-02-11 19:08:11 -08:00
Owen
a86cfa5934 Add missing col in migration 2026-02-11 19:03:23 -08:00
miloschwartz
54b77523c5 remove console.log 2026-02-11 19:02:22 -08:00
Owen
ba06c8928d Dont restrict numbers outside of the cloud 2026-02-11 19:01:47 -08:00
miloschwartz
c8a4ac1ed4 add global/org idp banner 2026-02-11 19:01:06 -08:00
miloschwartz
143acbae48 add identity provider mode setting 2026-02-11 18:05:06 -08:00
Owen
937f6fdae8 Add better error message 2026-02-11 17:56:58 -08:00
Owen
ba7239ac08 Verify everything we can 2026-02-11 17:30:21 -08:00
Owen
2e748274c0 Merge branch 'main' into dev 2026-02-11 17:22:19 -08:00
miloschwartz
17b6cb0c73 Merge branch 'new-pricing' into dev 2026-02-11 17:11:21 -08:00
miloschwartz
ce74489df5 link to billing page 2026-02-10 22:07:52 -08:00
Owen
342b188fae Fix setting limits 2026-02-10 21:54:26 -08:00
miloschwartz
fa6fee7b55 show required tier in paid features alert 2026-02-10 21:33:05 -08:00
Owen
c53d5a4d7d Add override for limits 2026-02-10 21:29:19 -08:00
miloschwartz
521e905724 use purple banner for all paid features alert 2026-02-10 21:21:20 -08:00
miloschwartz
4623090050 add subscription violation banner 2026-02-10 21:19:14 -08:00
Owen
dd9e5cc541 Remove s3 bucket 2026-02-10 21:14:14 -08:00
Owen
626be6a347 Allow cupons 2026-02-10 21:08:11 -08:00
Owen
56327ed503 Add final prices and fix logs 2026-02-10 20:42:34 -08:00
Owen
9ff863db5e Continue to clean things up 2026-02-10 18:30:01 -08:00
Owen
e2ac6e6d4d Communication improvements 2026-02-10 17:04:22 -08:00
Owen
df4101875a Show warnings and specifics when downgrading 2026-02-10 16:35:09 -08:00
Owen
3f5c788d48 Disable features when downgrading 2026-02-10 16:11:19 -08:00
miloschwartz
94ac3ec76e set default org mapping on create global idp 2026-02-10 10:58:01 -08:00
Owen
af7263a0b1 Finish adding limits checks to all put and post 2026-02-10 10:53:02 -08:00
Owen
035396f95c Fix error response 2026-02-10 10:53:02 -08:00
Owen
f318f6304b Adding limit checks 2026-02-10 10:53:02 -08:00
miloschwartz
9d0ff472e5 add tier matrix to branding page 2026-02-10 10:42:39 -08:00
miloschwartz
d27482e812 refactor and add tiers 2026-02-10 10:27:10 -08:00
miloschwartz
69c2212ea0 refactor front end hooks 2026-02-09 20:50:44 -08:00
Owen
10be9bcd56 Fix to use the limits file 2026-02-09 20:39:26 -08:00
Owen
f531def0d2 Comment out stripe usage reporting 2026-02-09 20:30:44 -08:00
miloschwartz
ed40eae655 fix some errors 2026-02-09 20:23:55 -08:00
Owen
ba5ae6ed04 Fix errors 2026-02-09 20:17:14 -08:00
Owen
0a6301697e Handle auto provisioning 2026-02-09 20:11:24 -08:00
Owen
13b4fc6725 Add more tier matrix checks 2026-02-09 19:52:44 -08:00
miloschwartz
a095dddd01 use pricing matrix in existing usePaidStatus funcitons 2026-02-09 18:17:18 -08:00
Owen
1b5cfaa49b Add pricing matrix 2026-02-09 18:04:37 -08:00
miloschwartz
66f3fabbae add rest of tier types 2026-02-09 17:52:28 -08:00
miloschwartz
0be8fb7931 add tier type 2026-02-09 17:42:45 -08:00
Owen
431e6ffaae Remove site kick 2026-02-09 17:23:48 -08:00
Owen
7d8185e0ee Getting swtiching tiers to work 2026-02-09 17:05:14 -08:00
miloschwartz
dff45748bd refactor is licensed and subscribed util functions 2026-02-09 16:57:41 -08:00
miloschwartz
e6464929ff Merge branch 'dev' into new-pricing 2026-02-09 15:05:13 -08:00
miloschwartz
122053939d dont fingerprint machine clients 2026-02-09 14:41:40 -08:00
Owen
300b4a3706 Set version when creating sub 2026-02-08 17:56:50 -08:00
Owen
81ef2db7f8 Rename tiers and get working 2026-02-08 17:56:36 -08:00
Owen
c41e8be3e8 Dont accept invite if over the limits 2026-02-08 11:55:24 -08:00
Owen
41bab0ce0b Dont log to stripe 2026-02-08 11:13:09 -08:00
Owen
5f26b9eeea Merge branch 'k8s' into new-pricing 2026-02-08 11:08:51 -08:00
Owen
1cca69ad23 Further billing 2026-02-08 11:08:23 -08:00
miloschwartz
410ed3949b use pangolin cli in machine client commands 2026-02-07 17:13:55 -08:00
miloschwartz
efc6ef3075 show features in ce 2026-02-07 17:00:44 -08:00
Owen
e101ac341b Basic billing page is working 2026-02-06 17:41:20 -08:00
Owen
6cfc7b7c69 Switch to the new tier system and clean up checks 2026-02-06 16:27:31 -08:00
Owen
313acabc86 Wrap insert in transaction
Ref #2222
2026-02-06 10:48:18 -08:00
Owen
34cced872f Switching to new pricing - remove old feature tracking 2026-02-06 10:47:43 -08:00
Owen
ac09e3aaf9 Wrap insert in transaction
Ref #2222
2026-02-06 10:47:19 -08:00
miloschwartz
a8f6b6c1da prefill username in login 2026-02-05 16:55:00 -08:00
Owen
f899326189 Change features, remove site uptime 2026-02-05 14:56:07 -08:00
Owen
f2ba4b270f Dont write stripe to files anymore 2026-01-29 20:56:46 -08:00
30 changed files with 734 additions and 920 deletions

View File

@@ -31,4 +31,5 @@ dist
migrations/
config/
build.ts
tsconfig.json
tsconfig.json
migrations/

View File

@@ -1,4 +1,4 @@
name: CI/CD Pipeline
name: Public Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
@@ -440,6 +440,10 @@ jobs:
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
# Track failures
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Determine if this is an RC release
IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then
@@ -471,94 +475,123 @@ jobs:
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
# Wrap the entire tag processing in error handling
(
set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# If index verification fails, attempt to verify child platform manifests
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
CHILD_VERIFIED=false
for ARCH in arm64 amd64; do
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
echo "==> cosign verify (public key) child ${CHILD_REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Public key verification succeeded for child ${CHILD_REF}"
else
echo "Public key verification failed for child ${CHILD_REF}"
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Keyless verification succeeded for child ${CHILD_REF}"
else
echo "Keyless verification failed for child ${CHILD_REF}"
fi
else
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
fi
done
if [ "${CHILD_VERIFIED}" != "true" ]; then
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
exit 10
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
fi
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# If index verification fails, attempt to verify child platform manifests
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
CHILD_VERIFIED=false
for ARCH in arm64 amd64; do
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
echo "==> cosign verify (public key) child ${CHILD_REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Public key verification succeeded for child ${CHILD_REF}"
else
echo "Public key verification failed for child ${CHILD_REF}"
fi
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Keyless verification succeeded for child ${CHILD_REF}"
else
echo "Keyless verification failed for child ${CHILD_REF}"
fi
else
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
fi
done
if [ "${CHILD_VERIFIED}" != "true" ]; then
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
exit 1
fi
fi
) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
fi
done
done
echo "All images signed and verified successfully!"
# Report summary
echo ""
echo "=========================================="
echo "Sign and Verify Summary"
echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag"
done
echo ""
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
else
echo "✓ All images signed and verified successfully!"
fi
shell: bash
post-run:

View File

@@ -1,426 +0,0 @@
name: CI/CD Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
permissions:
contents: read
packages: write # for GHCR push
id-token: write # for Cosign Keyless (OIDC) Signing
# Required secrets:
# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub
# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing
# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
pre-run:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instances
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
release-arm:
name: Build and Release (ARM64)
runs-on: [self-hosted, linux, arm64, us-east-1]
needs: [pre-run]
if: >-
${{
needs.pre-run.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Monitor storage space
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Used space is below the threshold of 75% free. Running Docker system prune."
echo y | docker system prune -a
else
echo "Storage space is above the threshold. No action needed."
fi
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Check if release candidate
id: check-rc
run: |
TAG=${{ env.TAG }}
if [[ "$TAG" == *"-rc."* ]]; then
echo "IS_RC=true" >> $GITHUB_ENV
else
echo "IS_RC=false" >> $GITHUB_ENV
fi
shell: bash
- name: Build and push Docker images (Docker Hub - ARM64)
run: |
TAG=${{ env.TAG }}
if [ "$IS_RC" = "true" ]; then
make build-rc-arm tag=$TAG
else
make build-release-arm tag=$TAG
fi
echo "Built & pushed ARM64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
shell: bash
release-amd:
name: Build and Release (AMD64)
runs-on: [self-hosted, linux, x64, us-east-1]
needs: [pre-run]
if: >-
${{
needs.pre-run.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Monitor storage space
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Used space is below the threshold of 75% free. Running Docker system prune."
echo y | docker system prune -a
else
echo "Storage space is above the threshold. No action needed."
fi
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Check if release candidate
id: check-rc
run: |
TAG=${{ env.TAG }}
if [[ "$TAG" == *"-rc."* ]]; then
echo "IS_RC=true" >> $GITHUB_ENV
else
echo "IS_RC=false" >> $GITHUB_ENV
fi
shell: bash
- name: Build and push Docker images (Docker Hub - AMD64)
run: |
TAG=${{ env.TAG }}
if [ "$IS_RC" = "true" ]; then
make build-rc-amd tag=$TAG
else
make build-release-amd tag=$TAG
fi
echo "Built & pushed AMD64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
shell: bash
create-manifest:
name: Create Multi-Arch Manifests
runs-on: [self-hosted, linux, x64, us-east-1]
needs: [release-arm, release-amd]
if: >-
${{
needs.release-arm.result == 'success' &&
needs.release-amd.result == 'success'
}}
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Log in to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Check if release candidate
id: check-rc
run: |
TAG=${{ env.TAG }}
if [[ "$TAG" == *"-rc."* ]]; then
echo "IS_RC=true" >> $GITHUB_ENV
else
echo "IS_RC=false" >> $GITHUB_ENV
fi
shell: bash
- name: Create multi-arch manifests
run: |
TAG=${{ env.TAG }}
if [ "$IS_RC" = "true" ]; then
make create-manifests-rc tag=$TAG
else
make create-manifests tag=$TAG
fi
echo "Created multi-arch manifests for tag: ${TAG}"
shell: bash
sign-and-package:
name: Sign and Package
runs-on: [self-hosted, linux, x64, us-east-1]
needs: [release-arm, release-amd, create-manifest]
if: >-
${{
needs.release-arm.result == 'success' &&
needs.release-amd.result == 'success' &&
needs.create-manifest.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }}
GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Install Go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version: 1.24
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Pull latest Gerbil version
id: get-gerbil-tag
run: |
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV
shell: bash
- name: Pull latest Badger version
id: get-badger-tag
run: |
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
shell: bash
- name: Update install/main.go
run: |
PANGOLIN_VERSION=${{ env.TAG }}
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
cat install/main.go
shell: bash
- name: Build installer
working-directory: install
run: |
make go-build-release
- name: Upload artifacts from /install/bin
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: install-bin
path: install/bin/
- name: Install skopeo + jq
# skopeo: copy/inspect images between registries
# jq: JSON parsing tool used to extract digest values
run: |
sudo apt-get update -y
sudo apt-get install -y skopeo jq
skopeo --version
shell: bash
- name: Login to GHCR
env:
REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json
run: |
mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")"
skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
shell: bash
- name: Copy tag from Docker Hub to GHCR
# Mirror the already-built image (all architectures) to GHCR so we can sign it
# Wait a bit for both architectures to be available in Docker Hub manifest
env:
REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json
run: |
set -euo pipefail
TAG=${{ env.TAG }}
echo "Waiting for multi-arch manifest to be ready..."
sleep 30
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:$TAG \
docker://$GHCR_IMAGE:$TAG
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
# cosign is used to sign and verify container images (key and keyless)
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Dual-sign and verify (GHCR & Docker Hub)
# Sign each image by digest using keyless (OIDC) and key-based signing,
# then verify both the public key signature and the keyless OIDC signature.
env:
TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true"
run: |
set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
echo "Processing ${IMAGE}:${TAG}"
DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')"
REF="${IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}"
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
echo "==> cosign verify (public key) ${REF}"
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
echo "==> cosign verify (keyless policy) ${REF}"
cosign verify \
--certificate-oidc-issuer "${issuer}" \
--certificate-identity-regexp "${id_regex}" \
"${REF}" -o text
done
shell: bash
post-run:
needs: [pre-run, release-arm, release-amd, create-manifest, sign-and-package]
if: >-
${{
always() &&
needs.pre-run.result == 'success' &&
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') &&
(needs.release-amd.result == 'success' || needs.release-amd.result == 'skipped' || needs.release-amd.result == 'failure') &&
(needs.create-manifest.result == 'success' || needs.create-manifest.result == 'skipped' || needs.create-manifest.result == 'failure') &&
(needs.sign-and-package.result == 'success' || needs.sign-and-package.result == 'skipped' || needs.sign-and-package.result == 'failure')
}}
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Stop EC2 instances
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"

View File

@@ -1,4 +1,4 @@
name: CI/CD Pipeline
name: SAAS Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Increase site count",
"idpManage": "Manage Identity Providers",
"idpManageDescription": "View and manage identity providers in the system",
"idpGlobalModeBanner": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the <adminPanelLink>admin panel</adminPanelLink>. To enable IdPs per organization, edit the server config and set IdP mode to org. <configDocsLink>See the docs</configDocsLink>. If you want to continue using global IdPs and make this disappear from the organization settings, explicitly set the mode to global in the config.",
"idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the <adminPanelLink>admin panel</adminPanelLink>. To use identity providers per organization, you must upgrade to the Enterprise edition.",
"idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the <adminPanelLink>admin panel</adminPanelLink>. To use identity providers per organization, an Enterprise license is required.",
"idpDeletedDescription": "Identity provider deleted successfully",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Are you sure you want to permanently delete the identity provider?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "End of following year",
"actionLogsDescription": "View a history of actions performed in this organization",
"accessLogsDescription": "View access auth requests for resources in this organization",
"licenseRequiredToUse": "An Enterprise license is required to use this feature.",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature.",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver",

View File

@@ -79,6 +79,7 @@ export const subscriptionItems = sqliteTable("subscriptionItems", {
subscriptionItemId: integer("subscriptionItemId").primaryKey({
autoIncrement: true
}),
stripeSubscriptionItemId: text("stripeSubscriptionItemId"),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.15.0";
export const APP_VERSION = "1.15.3";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -65,6 +65,11 @@ export class PrivateConfig {
this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
}
if (this.rawPrivateConfig.app.identity_provider_mode) {
process.env.IDENTITY_PROVIDER_MODE =
this.rawPrivateConfig.app.identity_provider_mode;
}
process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
?.logo?.auth_page?.width
? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
@@ -129,10 +134,6 @@ export class PrivateConfig {
process.env.USE_PANGOLIN_DNS =
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
}
if (this.rawPrivateConfig.flags.use_org_only_idp) {
process.env.USE_ORG_ONLY_IDP =
this.rawPrivateConfig.flags.use_org_only_idp.toString();
}
}
public getRawPrivateConfig() {

View File

@@ -25,7 +25,8 @@ export const privateConfigSchema = z.object({
app: z
.object({
region: z.string().optional().default("default"),
base_domain: z.string().optional()
base_domain: z.string().optional(),
identity_provider_mode: z.enum(["global", "org"]).optional()
})
.optional()
.default({
@@ -95,7 +96,7 @@ export const privateConfigSchema = z.object({
.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional()
})
.optional()
.prefault({}),
@@ -181,7 +182,29 @@ export const privateConfigSchema = z.object({
// localFilePath: z.string().optional()
})
.optional()
});
})
.transform((data) => {
// this to maintain backwards compatibility with the old config file
const identityProviderMode = data.app?.identity_provider_mode;
const useOrgOnlyIdp = data.flags?.use_org_only_idp;
if (identityProviderMode !== undefined) {
return data;
}
if (useOrgOnlyIdp === true) {
return {
...data,
app: { ...data.app, identity_provider_mode: "org" as const }
};
}
if (useOrgOnlyIdp === false) {
return {
...data,
app: { ...data.app, identity_provider_mode: "global" as const }
};
}
return data;
});
export function readPrivateConfigFile() {
if (build == "oss") {

View File

@@ -27,6 +27,7 @@ import config from "@server/lib/config";
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import privateConfig from "#private/lib/config";
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
@@ -92,6 +93,18 @@ export async function createOrgOidcIdp(
);
}
if (
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
"org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
)
);
}
const {
clientId,
clientSecret,

View File

@@ -22,6 +22,7 @@ import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig, idpOrg } from "@server/db";
import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import privateConfig from "#private/lib/config";
const paramsSchema = z
.object({
@@ -59,6 +60,18 @@ export async function deleteOrgIdp(
const { idpId } = parsedParams.data;
if (
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
"org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
)
);
}
// Check if IDP exists
const [existingIdp] = await db
.select()

View File

@@ -26,6 +26,7 @@ import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import privateConfig from "#private/lib/config";
const paramsSchema = z
.object({
@@ -97,6 +98,18 @@ export async function updateOrgOidcIdp(
);
}
if (
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
"org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
)
);
}
const { idpId, orgId } = parsedParams.data;
const {
clientId,

View File

@@ -26,6 +26,7 @@ import { generateId } from "@server/auth/sessions/app";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { getUniqueClientName } from "@server/db/names";
import { build } from "@server/build";
const createClientParamsSchema = z.strictObject({
orgId: z.string()
@@ -195,6 +196,12 @@ export async function createClient(
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
return next(
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`)
);
}
const [adminRole] = await trx
.select()
.from(roles)

View File

@@ -14,6 +14,7 @@ export default async function migration() {
db.transaction(() => {
db.prepare(`ALTER TABLE 'limits' ADD 'override' integer DEFAULT false;`).run();
db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'featureId' text;`).run();
db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'stripeSubscriptionItemId' text;`).run();
db.prepare(`ALTER TABLE 'subscriptions' ADD 'version' integer;`).run();
db.prepare(`ALTER TABLE 'subscriptions' ADD 'type' text;`).run();
})();

View File

@@ -5,6 +5,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "@app/components/OrgIdpTable";
import { getTranslations } from "next-intl/server";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type OrgIdpPageProps = {
@@ -36,6 +37,8 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
description={t("idpManageDescription")}
/>
<IdpGlobalModeBanner />
<PaidFeaturesAlert tiers={tierMatrix.orgOidc} />
<IdpTable idps={idps} orgId={params.orgId} />

View File

@@ -44,7 +44,6 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import type { OrgContextType } from "@app/contexts/orgContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isAppPageRouteDefinition } from "next/dist/server/route-definitions/app-page-route-definition";
// Session length options in hours
const SESSION_LENGTH_OPTIONS = [
@@ -219,6 +218,10 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (build != "saas") {
return true;
}
let maxDays: number;
if (!subscriptionTier) {
@@ -314,6 +317,10 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (build != "saas") {
return true;
}
let maxDays: number;
if (!subscriptionTier) {
@@ -411,6 +418,10 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(option) => {
if (build != "saas") {
return true;
}
let maxDays: number;
if (!subscriptionTier) {

View File

@@ -132,7 +132,7 @@ export default function ResourceAuthenticationPage() {
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
orgQueries.identityProviders({
orgId: org.org.orgId,
useOrgOnlyIdp: env.flags.useOrgOnlyIdp
useOrgOnlyIdp: env.app.identityProviderMode === "org"
})
);

View File

@@ -50,7 +50,7 @@ export default async function UsersPage(props: PageProps) {
title={t("userTitle")}
description={t("userDescription")}
/>
<Alert variant="neutral" className="mb-6">
<Alert className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("userAbount")}

View File

@@ -76,12 +76,13 @@ export default async function Page(props: {
// Only use SmartLoginForm if NOT (OSS build OR org-only IdP enabled)
const useSmartLogin =
build === "saas" || (build === "enterprise" && env.flags.useOrgOnlyIdp);
build === "saas" ||
(build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = [];
if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || !env.flags.useOrgOnlyIdp) {
if (build === "oss" || env.app.identityProviderMode !== "org") {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
@@ -165,7 +166,8 @@ export default async function Page(props: {
forceLogin={forceLogin}
showOrgLogin={
!isInvite &&
(build === "saas" || env.flags.useOrgOnlyIdp)
(build === "saas" ||
env.app.identityProviderMode === "org")
}
searchParams={searchParams}
defaultUser={defaultUser}
@@ -188,7 +190,8 @@ export default async function Page(props: {
</p>
)}
{!isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) ? (
{!isInvite &&
(build === "saas" || env.app.identityProviderMode === "org") ? (
<OrgSignInLink
href={`/auth/org${buildQueryString(searchParams)}`}
linkText={t("orgAuthSignInToOrg")}

View File

@@ -24,7 +24,7 @@ export default async function OrgAuthPage(props: {
const env = pullEnv();
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
if (build !== "saas" && env.app.identityProviderMode !== "org") {
const queryString = new URLSearchParams(searchParams as any).toString();
redirect(`/auth/login${queryString ? `?${queryString}` : ""}`);
}

View File

@@ -35,7 +35,7 @@ export default async function OrgAuthPage(props: {
const env = pullEnv();
if (build !== "saas" && !env.flags.useOrgOnlyIdp) {
if (build !== "saas" && env.app.identityProviderMode !== "org") {
redirect("/");
}

View File

@@ -202,7 +202,7 @@ export default async function ResourceAuthPage(props: {
}
let loginIdps: LoginFormIDP[] = [];
if (build === "saas" || env.flags.useOrgOnlyIdp) {
if (build === "saas" || env.app.identityProviderMode === "org") {
if (subscribed) {
const idpsRes = await cache(
async () =>

View File

@@ -124,7 +124,8 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
// PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" ||
env?.flags.useOrgOnlyIdp
env?.app.identityProviderMode === "org" ||
(env?.app.identityProviderMode === undefined && build !== "oss")
? [
{
title: "sidebarIdentityProviders",
@@ -251,7 +252,9 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
href: "/admin/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
...(build === "oss" || !env?.flags.useOrgOnlyIdp
...(build === "oss" ||
env?.app.identityProviderMode === "global" ||
env?.app.identityProviderMode === undefined
? [
{
title: "sidebarIdentityProviders",

View File

@@ -322,7 +322,7 @@ export default function AuthPageBrandingForm({
</div>
{build === "saas" ||
env.env.flags.useOrgOnlyIdp ? (
env.env.app.identityProviderMode === "org" ? (
<>
<div className="mt-3 mb-6">
<SettingsSectionTitle>

View File

@@ -0,0 +1,65 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Info } from "lucide-react";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
export function IdpGlobalModeBanner() {
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser, hasEnterpriseLicense } = usePaidStatus();
const identityProviderModeUndefined =
env.app.identityProviderMode === undefined;
const paidUserForOrgOidc = isPaidUser(tierMatrix.orgOidc);
const enterpriseUnlicensed =
build === "enterprise" && !hasEnterpriseLicense;
if (build === "saas") {
return null;
}
if (!identityProviderModeUndefined) {
return null;
}
const adminPanelLinkRenderer = (chunks: React.ReactNode) => (
<Link href="/admin/idp" className="font-medium underline">
{chunks}
</Link>
);
return (
<Alert className="mb-6">
<Info className="h-4 w-4" />
<AlertDescription>
{paidUserForOrgOidc
? t.rich("idpGlobalModeBanner", {
adminPanelLink: adminPanelLinkRenderer,
configDocsLink: (chunks) => (
<Link
href="https://docs.pangolin.net/manage/identity-providers/add-an-idp#organization-identity-providers"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
{chunks}
</Link>
)
})
: enterpriseUnlicensed
? t.rich("idpGlobalModeBannerLicenseRequired", {
adminPanelLink: adminPanelLinkRenderer
})
: t.rich("idpGlobalModeBannerUpgradeRequired", {
adminPanelLink: adminPanelLinkRenderer
})}
</AlertDescription>
</Alert>
);
}

View File

@@ -38,15 +38,49 @@ const bannerContentClassName = "py-3 px-4";
const bannerRowClassName =
"flex items-center gap-2.5 text-sm text-muted-foreground";
const bannerIconClassName = "size-4 shrink-0 text-purple-500";
const docsLinkClassName =
"inline-flex items-center gap-1 font-medium text-purple-600 underline";
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
function getTierLinkRenderer(billingHref: string) {
return function tierLinkRenderer(chunks: React.ReactNode) {
return (
<Link href={billingHref} className={docsLinkClassName}>
{chunks}
</Link>
);
};
}
function getPangolinCloudLinkRenderer() {
return function pangolinCloudLinkRenderer(chunks: React.ReactNode) {
return (
<Link
href={billingHref}
className="inline-flex items-center gap-1 font-medium text-purple-600 underline"
href={PANGOLIN_CLOUD_SIGNUP_URL}
target="_blank"
rel="noopener noreferrer"
className={docsLinkClassName}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
);
};
}
function getDocsLinkRenderer(href: string) {
return function docsLinkRenderer(chunks: React.ReactNode) {
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className={docsLinkClassName}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
);
};
@@ -66,6 +100,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null;
const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing";
const tierLinkRenderer = getTierLinkRenderer(billingHref);
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
if (env.flags.disableEnterpriseFeatures) {
return null;
@@ -103,7 +139,12 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} />
<span>{t("licenseRequiredToUse")}</span>
<span>
{t.rich("licenseRequiredToUse", {
enterpriseLicenseLink: enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>
</div>
</CardContent>
</Card>
@@ -116,17 +157,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: (chunks) => (
<Link
href="https://docs.pangolin.net/self-host/enterprise-edition"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-purple-600 underline"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
)
enterpriseEditionLink: enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>
</div>

View File

@@ -118,7 +118,7 @@ function getActionsCategories(root: boolean) {
}
};
if (root || build === "saas" || env.flags.useOrgOnlyIdp) {
if (root || build === "saas" || env.app.identityProviderMode === "org") {
actionsByCategory["Identity Provider (IDP)"] = {
[t("actionCreateIdp")]: "createIdp",
[t("actionUpdateIdp")]: "updateIdp",

View File

@@ -204,7 +204,9 @@ export default function SignupForm({
? env.branding.logo?.authPage?.height || 44
: 44;
const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp);
const showOrgBanner =
fromSmartLogin &&
(build === "saas" || env.app.identityProviderMode === "org");
const orgBannerHref = redirect
? `/auth/org?redirect=${encodeURIComponent(redirect)}`
: "/auth/org";
@@ -226,388 +228,398 @@ export default function SignupForm({
</Alert>
)}
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={!!emailParam}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t("password")}</FormLabel>
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setPasswordValue(
e.target.value
);
}}
className={cn(
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
passwordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
disabled={!!emailParam}
/>
</div>
</FormControl>
{passwordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">
{t("passwordStrength")}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">
{t("passwordRequirements")}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{passwordValue.length === 0 && (
</FormControl>
<FormMessage />
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("confirmPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("password")}
</FormLabel>
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
</FormControl>
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
{build === "saas" && (
<>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(
checked
) => {
field.onChange(checked);
handleTermsChange(
checked as boolean
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setPasswordValue(
e.target.value
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingEmailConsent"
render={({ field }) => (
<FormItem className="flex flex-row items-start">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t(
"signUpMarketing.keepMeInTheLoop"
className={cn(
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
passwordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
</FormLabel>
<FormMessage />
autoComplete="new-password"
/>
</div>
</FormItem>
)}
/>
</>
)}
</FormControl>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{passwordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">
{t(
"passwordStrength"
)}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
<Button type="submit" className="w-full">
{t("createAccount")}
</Button>
</form>
</Form>
</CardContent>
</Card>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">
{t(
"passwordRequirements"
)}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{passwordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("confirmPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
{build === "saas" && (
<>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(
checked
) => {
field.onChange(
checked
);
handleTermsChange(
checked as boolean
);
}}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t(
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingEmailConsent"
render={({ field }) => (
<FormItem className="flex flex-row items-start">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t(
"signUpMarketing.keepMeInTheLoop"
)}
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full">
{t("createAccount")}
</Button>
</form>
</Form>
</CardContent>
</Card>
</>
);
}

View File

@@ -32,7 +32,11 @@ export function pullEnv(): Env {
process.env.NEW_RELEASES_NOTIFICATION_ENABLED === "true"
? true
: false
}
},
identityProviderMode: process.env.IDENTITY_PROVIDER_MODE as
| "org"
| "global"
| undefined
},
email: {
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false
@@ -64,8 +68,6 @@ export function pullEnv(): Env {
process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS === "true"
? true
: false,
useOrgOnlyIdp:
process.env.USE_ORG_ONLY_IDP === "true" ? true : false,
disableEnterpriseFeatures:
process.env.DISABLE_ENTERPRISE_FEATURES === "true"
? true

View File

@@ -8,6 +8,7 @@ export type Env = {
product_updates: boolean;
new_releases: boolean;
};
identityProviderMode?: "global" | "org";
};
server: {
externalPort: string;
@@ -34,7 +35,6 @@ export type Env = {
hideSupporterKey: boolean;
usePangolinDns: boolean;
disableProductHelpBanners: boolean;
useOrgOnlyIdp: boolean;
disableEnterpriseFeatures: boolean;
};
branding: {