Compare commits

...

158 Commits

Author SHA1 Message Date
dependabot[bot]
3488e50ba6 Bump fast-xml-parser, @aws-sdk/client-s3 and @aws-sdk/client-sesv2
Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) to 5.3.4 and updates ancestor dependencies [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser), [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) and [@aws-sdk/client-sesv2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sesv2). These dependencies need to be updated together.


Updates `fast-xml-parser` from 5.2.5 to 5.3.4
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.2.5...v5.3.4)

Updates `@aws-sdk/client-s3` from 3.971.0 to 3.987.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.987.0/clients/client-s3)

Updates `@aws-sdk/client-sesv2` from 3.946.0 to 3.987.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sesv2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.987.0/clients/client-sesv2)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.3.4
  dependency-type: indirect
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.987.0
  dependency-type: direct:production
- dependency-name: "@aws-sdk/client-sesv2"
  dependency-version: 3.987.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-15 19:12:41 +00:00
miloschwartz
9eacefb155 support delete account 2026-02-14 22:44:30 -08:00
Owen
843b13ed57 Try to fix cicd 2026-02-13 15:00:17 -08:00
Owen
be89e5ca55 Fix issue with auto provisioning being overriden 2026-02-13 14:56:56 -08:00
miloschwartz
333625f199 rename starter in cloud to basic 2026-02-12 20:24:23 -08:00
Owen
dbfd715381 Fix windows formatting 2026-02-12 16:27:51 -08:00
Owen Schwartz
f1d989964e Merge pull request #2471 from fosrl/dev
Update translations
2026-02-12 16:14:41 -08:00
Owen Schwartz
b701629498 Merge pull request #2465 from fosrl/crowdin_dev
New Crowdin updates
2026-02-12 16:14:17 -08:00
Owen Schwartz
8250946325 New translations en-us.json (Norwegian Bokmal) 2026-02-12 16:14:04 -08:00
Owen Schwartz
71f63d8e6f New translations en-us.json (Chinese Simplified) 2026-02-12 16:14:03 -08:00
Owen Schwartz
dd5e834db0 New translations en-us.json (Turkish) 2026-02-12 16:14:01 -08:00
Owen Schwartz
970ecb52f0 New translations en-us.json (Russian) 2026-02-12 16:14:00 -08:00
Owen Schwartz
62ea1b40e1 New translations en-us.json (Portuguese) 2026-02-12 16:13:58 -08:00
Owen Schwartz
3b0fd5c592 New translations en-us.json (Polish) 2026-02-12 16:13:57 -08:00
Owen Schwartz
b7616026dd New translations en-us.json (Dutch) 2026-02-12 16:13:55 -08:00
Owen Schwartz
16ad60b89a New translations en-us.json (Korean) 2026-02-12 16:13:54 -08:00
Owen Schwartz
db7971d2f7 New translations en-us.json (Italian) 2026-02-12 16:13:53 -08:00
Owen Schwartz
f3f8bd3125 New translations en-us.json (German) 2026-02-12 16:13:51 -08:00
Owen Schwartz
516fd0ee8f New translations en-us.json (Czech) 2026-02-12 16:13:50 -08:00
Owen Schwartz
8d6700d493 New translations en-us.json (Bulgarian) 2026-02-12 16:13:49 -08:00
Owen Schwartz
9d4ace9b3e New translations en-us.json (Spanish) 2026-02-12 16:13:47 -08:00
Owen Schwartz
2800655e33 New translations en-us.json (French) 2026-02-12 16:13:45 -08:00
Owen Schwartz
91eecee11d Merge pull request #2469 from fosrl/dev
1.15.4
2026-02-12 16:10:44 -08:00
Owen Schwartz
899e5aa395 New translations en-us.json (Norwegian Bokmal) 2026-02-12 15:41:25 -08:00
Owen Schwartz
d5820c4902 New translations en-us.json (Chinese Simplified) 2026-02-12 15:41:24 -08:00
Owen Schwartz
a91c002274 New translations en-us.json (Turkish) 2026-02-12 15:41:22 -08:00
Owen Schwartz
4d142b93dd New translations en-us.json (Russian) 2026-02-12 15:41:21 -08:00
Owen Schwartz
04dcf57ff3 New translations en-us.json (Portuguese) 2026-02-12 15:41:20 -08:00
Owen Schwartz
975550c755 New translations en-us.json (Polish) 2026-02-12 15:41:18 -08:00
Owen Schwartz
a964a80d85 New translations en-us.json (Dutch) 2026-02-12 15:41:17 -08:00
Owen Schwartz
22c3b8f116 New translations en-us.json (Korean) 2026-02-12 15:41:15 -08:00
Owen Schwartz
c4b1831cfe New translations en-us.json (Italian) 2026-02-12 15:41:14 -08:00
Owen Schwartz
cdb6813384 New translations en-us.json (German) 2026-02-12 15:41:13 -08:00
Owen Schwartz
b14b68d83c New translations en-us.json (Czech) 2026-02-12 15:41:11 -08:00
Owen Schwartz
3c2f930e6b New translations en-us.json (Bulgarian) 2026-02-12 15:41:10 -08:00
Owen Schwartz
ca9c7ce555 New translations en-us.json (Spanish) 2026-02-12 15:41:08 -08:00
Owen Schwartz
c2e95a0607 New translations en-us.json (French) 2026-02-12 15:41:06 -08:00
miloschwartz
2767ee9e80 update pangolin cli links 2026-02-12 15:29:08 -08:00
miloschwartz
d998a8087f fix pg migration 2026-02-12 15:06:24 -08:00
miloschwartz
fdce016921 add 1.15.4 migration 2026-02-12 15:00:12 -08:00
miloschwartz
c73d70933b bump version 2026-02-12 14:52:29 -08:00
miloschwartz
e9d0ad6e37 use pangolin cli for container 2026-02-12 14:51:25 -08:00
Owen
a35586f762 Add sudo 2026-02-12 14:47:55 -08:00
miloschwartz
f527c30923 add post auth url 2026-02-12 14:21:50 -08:00
Owen
94e70219cf Make install sudo because run is sudo 2026-02-12 14:12:45 -08:00
Owen
6496763aae Cap retention days 2026-02-12 12:18:42 -08:00
Owen
a409ec269b Change back to lokowitz db method 2026-02-12 12:13:13 -08:00
Owen
bc7bc8da66 Stop tracking files that should be ignored 2026-02-12 12:07:57 -08:00
Owen
52484c774e Setting up drizzle and fix site not showing in private resource 2026-02-12 12:05:15 -08:00
Owen Schwartz
69ecc22318 New translations en-us.json (German) 2026-02-12 01:39:38 -08:00
Owen
bff9d33ee6 Move back to db:sqlite:generate 2026-02-11 21:47:10 -08:00
Owen Schwartz
edf506953b Merge pull request #2463 from fosrl/dev
1.15.3
2026-02-11 21:42:04 -08:00
Owen Schwartz
5e11746549 New translations en-us.json (Norwegian Bokmal) 2026-02-11 21:40:26 -08:00
Owen Schwartz
1ae315e303 New translations en-us.json (Chinese Simplified) 2026-02-11 21:40:26 -08:00
Owen Schwartz
758b03ab25 New translations en-us.json (Turkish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
e756fad573 New translations en-us.json (Russian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
3547450b03 New translations en-us.json (Portuguese) 2026-02-11 21:40:26 -08:00
Owen Schwartz
733f6692c6 New translations en-us.json (Polish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
2d83160b16 New translations en-us.json (Dutch) 2026-02-11 21:40:26 -08:00
Owen Schwartz
256fa880dd New translations en-us.json (Korean) 2026-02-11 21:40:26 -08:00
Owen Schwartz
b08c5f5c67 New translations en-us.json (Italian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
d0862a2d26 New translations en-us.json (German) 2026-02-11 21:40:26 -08:00
Owen Schwartz
e97340ed52 New translations en-us.json (Czech) 2026-02-11 21:40:26 -08:00
Owen Schwartz
e27c81eea6 New translations en-us.json (Bulgarian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
7f7f3d43b2 New translations en-us.json (Spanish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
4b1b772098 New translations en-us.json (French) 2026-02-11 21:40:26 -08:00
Owen Schwartz
f66b88490f New translations en-us.json (Norwegian Bokmal) 2026-02-11 21:40:26 -08:00
Owen Schwartz
18f9157169 New translations en-us.json (Chinese Simplified) 2026-02-11 21:40:26 -08:00
Owen Schwartz
6eb82a807b New translations en-us.json (Turkish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
bf57a97833 New translations en-us.json (Russian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
e9e2093220 New translations en-us.json (Portuguese) 2026-02-11 21:40:26 -08:00
Owen Schwartz
c3540da2e3 New translations en-us.json (Polish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
d228cf56dd New translations en-us.json (Dutch) 2026-02-11 21:40:26 -08:00
Owen Schwartz
8f4cecd963 New translations en-us.json (Korean) 2026-02-11 21:40:26 -08:00
Owen Schwartz
66adff44bb New translations en-us.json (Italian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
be41c094dc New translations en-us.json (German) 2026-02-11 21:40:26 -08:00
Owen Schwartz
273848ca18 New translations en-us.json (Czech) 2026-02-11 21:40:26 -08:00
Owen Schwartz
1e9dbead3b New translations en-us.json (Bulgarian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
aeaa8ba133 New translations en-us.json (Spanish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
24654af635 New translations en-us.json (French) 2026-02-11 21:40:26 -08:00
Owen Schwartz
e88a21d6db New translations en-us.json (Norwegian Bokmal) 2026-02-11 21:40:26 -08:00
Owen Schwartz
bcd01badaf New translations en-us.json (Chinese Simplified) 2026-02-11 21:40:26 -08:00
Owen Schwartz
8e063506e0 New translations en-us.json (Turkish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
84f5d6137a New translations en-us.json (Russian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
0a8565f5e8 New translations en-us.json (Portuguese) 2026-02-11 21:40:26 -08:00
Owen Schwartz
bd8da25a46 New translations en-us.json (Polish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
a841f588dd New translations en-us.json (Dutch) 2026-02-11 21:40:26 -08:00
Owen Schwartz
75a4362ce3 New translations en-us.json (Korean) 2026-02-11 21:40:26 -08:00
Owen Schwartz
e763e001e5 New translations en-us.json (Italian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
69475a0ae7 New translations en-us.json (German) 2026-02-11 21:40:26 -08:00
Owen Schwartz
53e14c2ad7 New translations en-us.json (Czech) 2026-02-11 21:40:26 -08:00
Owen Schwartz
1edc33148a New translations en-us.json (Bulgarian) 2026-02-11 21:40:26 -08:00
Owen Schwartz
a4cbfc74e4 New translations en-us.json (Spanish) 2026-02-11 21:40:26 -08:00
Owen Schwartz
c0d25aeb02 New translations en-us.json (French) 2026-02-11 21:40:26 -08:00
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
Owen
eab2750953 Add migrations for 1.15.3 2026-02-11 17:21:15 -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
92 changed files with 2562 additions and 2528 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 CICD 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,92 @@ 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
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
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

@@ -37,7 +37,7 @@ jobs:
run: npm run db:generate
- name: Apply database migrations
run: npm run db:sqlite:push
run: npm run db:push
- name: Test with tsc
run: npx tsc --noEmit

3
.gitignore vendored
View File

@@ -51,4 +51,5 @@ dynamic/
scratch/
tsconfig.json
hydrateSaas.ts
CLAUDE.md
CLAUDE.md
drizzle.config.ts

View File

@@ -16,7 +16,7 @@ COPY . .
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
npm run set:$DATABASE && \
npm run set:$BUILD && \
npm run db:$DATABASE:generate && \
npm run db:generate && \
npm run build && \
npm run build:cli

View File

@@ -1,14 +0,0 @@
import { defineConfig } from "drizzle-kit";
import path from "path";
const schema = [path.join("server", "db", "pg", "schema")];
export default defineConfig({
dialect: "postgresql",
schema: schema,
out: path.join("server", "migrations"),
verbose: true,
dbCredentials: {
url: process.env.DATABASE_URL as string
}
});

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Увеличаване на броя на сайтовете",
"idpManage": "Управление на доставчици на идентичност",
"idpManageDescription": "Прегледайте и управлявайте доставчици на идентичност в системата",
"idpGlobalModeBanner": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в <adminPanelLink>администраторския панел</adminPanelLink>. За да активирате IdPs за всяка организация, редактирайте конфигурацията на сървъра и задайте режима на IdP към org. <configDocsLink>Вижте документацията</configDocsLink>. Ако желаете да продължите да използвате глобалните IdPs и да премахнете това от настройките на организацията, изрично задайте режима на global в конфигурацията.",
"idpGlobalModeBannerUpgradeRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в <adminPanelLink>администраторския панел</adminPanelLink>. За да използвате доставчици на идентичност за всяка организация, трябва да надстроите до изданието Enterprise.",
"idpGlobalModeBannerLicenseRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в <adminPanelLink>администраторския панел</adminPanelLink>. За да използвате доставчици на идентичност за всяка организация, е необходим лиценз за изданието Enterprise.",
"idpDeletedDescription": "Доставчик на идентичност успешно изтрит",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Сигурни ли сте, че искате да изтриете доставчика за идентичност?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Край на следващата година",
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
"licenseRequiredToUse": "Необходим е лиценз Enterprise, за да се използва тази функция.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> се изисква за използване на тази функция.",
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "Необходимо е <enterpriseEditionLink>изданието Enterprise</enterpriseEditionLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Решавач на сертификати",
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
"selectCertResolver": "Изберете решавач на сертификати",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Zvýšit počet stránek",
"idpManage": "Spravovat poskytovatele identity",
"idpManageDescription": "Zobrazit a spravovat poskytovatele identity v systému",
"idpGlobalModeBanner": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v <adminPanelLink>admin panelu</adminPanelLink>. Chcete-li povolit IdP pro každou organizaci, upravte konfiguraci serveru a nastavte IdP režim na org. <configDocsLink>Viz dokumentace</configDocsLink>. Pokud chcete pokračovat v používání globálních IdP a zmizet z nastavení organizace, explicitně nastavte režim na globální v konfiguraci.",
"idpGlobalModeBannerUpgradeRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Spravujte globální IdP v <adminPanelLink>admin panelu</adminPanelLink>. Chcete-li použít poskytovatele identity pro každou organizaci, musíte přejít na Enterprise vydání.",
"idpGlobalModeBannerLicenseRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v <adminPanelLink>admin panelu</adminPanelLink>. Chcete-li použít poskytovatele identity pro každou organizaci, je vyžadována Enterprise licence.",
"idpDeletedDescription": "Poskytovatel identity byl úspěšně odstraněn",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Jste si jisti, že chcete trvale odstranit poskytovatele identity?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Konec následujícího roku",
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence pro podnikání.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce.",
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Tato funkce je také dostupná v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Oddělovač certifikátů",
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
"selectCertResolver": "Vyberte řešič certifikátů",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Anzahl der Standorte erhöhen",
"idpManage": "Identitätsanbieter verwalten",
"idpManageDescription": "Identitätsanbieter im System anzeigen und verwalten",
"idpGlobalModeBanner": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt über alle Organisationen). Verwalten Sie globale IdPs im <adminPanelLink>Admin-Panel</adminPanelLink>. Um IdPs pro Organisation zu aktivieren, bearbeiten Sie die Server-Konfiguration und setzen Sie den IdP-Modus auf org. <configDocsLink>Siehe Dokumentation</configDocsLink>. Wenn Sie weiterhin globale IdPs verwenden und diese in den Organisationseinstellungen verschwinden lassen wollen, setzen Sie den Modus explizit auf global in der Konfiguration.",
"idpGlobalModeBannerUpgradeRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im <adminPanelLink>Admin-Panel</adminPanelLink>verwalten. Um Identitätsanbieter pro Organisation nutzen zu können, müssen Sie zur Enterprise Edition upgraden.",
"idpGlobalModeBannerLicenseRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im <adminPanelLink>Admin-Panel</adminPanelLink>verwalten. Um Identitätsanbieter pro Organisation zu verwenden, ist eine Enterprise-Lizenz erforderlich.",
"idpDeletedDescription": "Identitätsanbieter erfolgreich gelöscht",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Sind Sie sicher, dass Sie den Identitätsanbieter dauerhaft löschen möchten?",
@@ -1151,7 +1154,7 @@
"actionDeleteClient": "Client löschen",
"actionArchiveClient": "Client archivieren",
"actionUnarchiveClient": "Client dearchivieren",
"actionBlockClient": "Klient sperren",
"actionBlockClient": "Client sperren",
"actionUnblockClient": "Client entsperren",
"actionUpdateClient": "Client aktualisieren",
"actionListClients": "Clients auflisten",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
"licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine Enterprise-Lizenz erforderlich.",
"ossEnterpriseEditionRequired": "Die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> wird benötigt, um diese Funktion nutzen zu können.",
"licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
"ossEnterpriseEditionRequired": "Um diese Funktion nutzen zu können, ist die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
"certResolver": "Zertifikatsauflöser",
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
"selectCertResolver": "Zertifikatsauflöser auswählen",
@@ -2529,10 +2532,10 @@
"archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?",
"archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.",
"archiveClientConfirm": "Client archivieren",
"blockClient": "Klient sperren",
"blockClient": "Client sperren",
"blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?",
"blockClientMessage": "Das Gerät wird gezwungen, die Verbindung zu trennen, wenn es gerade verbunden ist. Sie können das Gerät später entsperren.",
"blockClientConfirm": "Klient sperren",
"blockClientConfirm": "Client sperren",
"active": "Aktiv",
"usernameOrEmail": "Benutzername oder E-Mail",
"selectYourOrganization": "Wählen Sie Ihre Organisation",

View File

@@ -201,6 +201,7 @@
"protocolSelect": "Select a protocol",
"resourcePortNumber": "Port Number",
"resourcePortNumberDescription": "The external port number to proxy requests.",
"back": "Back",
"cancel": "Cancel",
"resourceConfig": "Configuration Snippets",
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "An error occurred while deleting the organization.",
"orgDeleted": "Organization deleted",
"orgDeletedMessage": "The organization and its data has been deleted.",
"deleteAccount": "Delete Account",
"deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.",
"deleteAccountButton": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account",
"deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.",
"deleteAccountConfirmString": "delete account",
"deleteAccountSuccess": "Account Deleted",
"deleteAccountSuccessMessage": "Your account has been deleted.",
"deleteAccountError": "Failed to delete account",
"deleteAccountPreviewAccount": "Your Account",
"deleteAccountPreviewOrgs": "Organizations you own (and all their data)",
"orgMissing": "Organization ID Missing",
"orgMissingMessage": "Unable to regenerate invitation without an organization ID.",
"accessUsersManage": "Manage Users",
@@ -791,6 +803,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?",
@@ -2057,7 +2072,7 @@
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
"machineClientsBannerPangolinCLI": "Pangolin CLI",
"machineClientsBannerOlmCLI": "Olm CLI",
"machineClientsBannerOlmContainer": "Olm Container",
"machineClientsBannerOlmContainer": "Container",
"clientsTableUserClients": "User",
"clientsTableMachineClients": "Machine",
"licenseTableValidUntil": "Valid Until",
@@ -2280,8 +2295,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

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Aumentar el número de sitios",
"idpManage": "Administrar proveedores de identidad",
"idpManageDescription": "Ver y administrar proveedores de identidad en el sistema",
"idpGlobalModeBanner": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el <adminPanelLink>panel de administración</adminPanelLink>. Para habilitar los IdPs por organización, edita la configuración del servidor y establece el modo de IdP en org. <configDocsLink>Consulta la documentación</configDocsLink>. Si deseas seguir utilizando IdPs globales y hacer que esto desaparezca de las configuraciones de la organización, establece explícitamente el modo en global en la configuración.",
"idpGlobalModeBannerUpgradeRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el <adminPanelLink>panel de administración</adminPanelLink>. Para usar proveedores de identidad por organización, debes actualizar a la edición Empresarial.",
"idpGlobalModeBannerLicenseRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando identificadores globales (compartidos en todas las organizaciones). Gestionar identificaciones globales en el panel <adminPanelLink>de administración</adminPanelLink>. Para utilizar proveedores de identidad por organización, se requiere una licencia de empresa.",
"idpDeletedDescription": "Proveedor de identidad eliminado correctamente",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "¿Está seguro que desea eliminar permanentemente el proveedor de identidad?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
"licenseRequiredToUse": "Se requiere una licencia Enterprise para utilizar esta función.",
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> es necesaria para utilizar esta función.",
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> para utilizar esta función. Esta característica también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>versión Enterprise</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Resolver certificado",
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
"selectCertResolver": "Seleccionar Resolver Certificado",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Augmenter le nombre de sites",
"idpManage": "Gérer les fournisseurs d'identité",
"idpManageDescription": "Voir et gérer les fournisseurs d'identité dans le système",
"idpGlobalModeBanner": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérez les IdPs globaux dans le panneau d'administration <adminPanelLink></adminPanelLink>. Pour activer les IdPs par organisation, éditez la configuration du serveur et réglez le mode IdP sur org. <configDocsLink>Voir la documentation</configDocsLink>. Si vous voulez continuer à utiliser les IdPs globaux et faire disparaître cela des paramètres de l'organisation, définissez explicitement le mode à global dans la configuration.",
"idpGlobalModeBannerUpgradeRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration <adminPanelLink></adminPanelLink>. Pour utiliser les fournisseurs d'identité par organisation, vous devez passer à l'édition Entreprise.",
"idpGlobalModeBannerLicenseRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration <adminPanelLink></adminPanelLink>. Pour utiliser les fournisseurs d'identité par organisation, une licence d'entreprise est requise.",
"idpDeletedDescription": "Fournisseur d'identité supprimé avec succès",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Êtes-vous sûr de vouloir supprimer définitivement le fournisseur d'identité?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Fin de l'année suivante",
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
"licenseRequiredToUse": "Une licence Entreprise est nécessaire pour utiliser cette fonctionnalité.",
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité.",
"licenseRequiredToUse": "Une licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> est nécessaire pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Résolveur de certificat",
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
"selectCertResolver": "Sélectionnez le résolveur de certificat",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Aumenta conteggio siti",
"idpManage": "Gestisci Provider di Identità",
"idpManageDescription": "Visualizza e gestisci i provider di identità nel sistema",
"idpGlobalModeBanner": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Sta utilizzando IdP globali (condivisi in tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione <adminPanelLink></adminPanelLink>. Per abilitare IdP per organizzazione, modificare la configurazione del server e impostare la modalità IdP su org. <configDocsLink>Vedere i documenti</configDocsLink>. Se si desidera continuare a utilizzare IdP globali e far sparire questo dalle impostazioni dell'organizzazione, impostare esplicitamente la modalità globale nella configurazione.",
"idpGlobalModeBannerUpgradeRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci gli IdP globali nel pannello di amministrazione <adminPanelLink></adminPanelLink>. Per utilizzare i provider di identità per organizzazione, è necessario aggiornare all'edizione Enterprise.",
"idpGlobalModeBannerLicenseRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione <adminPanelLink></adminPanelLink>. Per utilizzare provider di identità per organizzazione, è richiesta una licenza Enterprise.",
"idpDeletedDescription": "Provider di identità eliminato con successo",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Sei sicuro di voler eliminare definitivamente il provider di identità?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise.",
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione.",
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Risolutore Di Certificato",
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
"selectCertResolver": "Seleziona Risolutore Di Certificato",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "사이트 수 증가",
"idpManage": "아이덴티티 공급자 관리",
"idpManageDescription": "시스템에서 ID 제공자를 보고 관리합니다",
"idpGlobalModeBanner": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. <adminPanelLink>관리자 패널</adminPanelLink>에서 글로벌 IdP를 관리하십시오. 조직별 IdP를 활성화하려면 서버 설정을 편집하고 IdP 모드를 조직으로 설정하십시오. <configDocsLink>문서 보기</configDocsLink>. 글로벌 IdP 사용을 계속하고 조직 설정에서 이 항목을 제거하려면 설정에서 모드를 글로벌로 명시적으로 설정하십시오.",
"idpGlobalModeBannerUpgradeRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. <adminPanelLink>관리자 패널</adminPanelLink>에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 Enterprise 에디션으로 업그레이드해야 합니다.",
"idpGlobalModeBannerLicenseRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. <adminPanelLink>관리자 패널</adminPanelLink>에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 엔터프라이즈 라이선스가 필요합니다.",
"idpDeletedDescription": "신원 공급자가 성공적으로 삭제되었습니다",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "아이덴티티 공급자를 영구적으로 삭제하시겠습니까?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "다음 연도 말",
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
"licenseRequiredToUse": "이 기능을 사용하려면 Enterprise 라이선스가 필요합니다.",
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink>이 필요합니다.",
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
"certResolver": "인증서 해결사",
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
"selectCertResolver": "인증서 해결사 선택",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Øk antall områder",
"idpManage": "Administrer Identitetsleverandører",
"idpManageDescription": "Vis og administrer identitetsleverandører i systemet",
"idpGlobalModeBanner": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdP (delt over alle organisasjoner). Administrer globale IdP'er i <adminPanelLink>admin-panelet</adminPanelLink>. For å aktivere IdP per organisasjon, rediger serverkonfigurasjonen og sett IdP-modus til org. <configDocsLink>Se dokumentasjonen</configDocsLink>. Hvis du vil fortsette å bruke globale IdPs og få denne til å forsvinne fra organisasjonens innstillinger, satt eksplisitt modusen til global i konfigurasjonen.",
"idpGlobalModeBannerUpgradeRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i <adminPanelLink>administrasjons-panelet</adminPanelLink>. For å bruke identitetsleverandører per organisasjon, må du oppgradere til Enterprise-utgaven.",
"idpGlobalModeBannerLicenseRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i <adminPanelLink>administrasjons-panelet</adminPanelLink>. For å bruke identitetsleverandører per organisasjon, kreves en Enterprise-lisens.",
"idpDeletedDescription": "Identitetsleverandør slettet vellykket",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Er du sikker på at du vil slette identitetsleverandøren permanent?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Slutt på neste år",
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
"licenseRequiredToUse": "En Enterprise lisens er påkrevd for å bruke denne funksjonen.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen.",
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens er påkrevd for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Sertifikat løser",
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
"selectCertResolver": "Velg sertifikatløser",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Toename van site vergroten",
"idpManage": "Identiteitsaanbieders beheren",
"idpManageDescription": "Identiteitsaanbieders in het systeem bekijken en beheren",
"idpGlobalModeBanner": "Identiteitsaanbieders (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld tussen alle organisaties). Beheer globale IdPs in het <adminPanelLink>beheerderspaneel</adminPanelLink>. Om IdPs per organisatie in te schakelen, bewerk de server configuratie en zet IdP modus op org. <configDocsLink>Zie de documenten</configDocsLink>. Als je globale IdPs wilt blijven gebruiken en dit uit de organisatie-instellingen wilt laten verdwijnen, zet dan expliciet de modus naar globaal in de config.",
"idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het <adminPanelLink>beheerderspaneel</adminPanelLink>. Om identiteitsproviders per organisatie te gebruiken, moet u upgraden naar de Enterprise editie.",
"idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het <adminPanelLink>beheerderspaneel</adminPanelLink>. Om identiteitsaanbieders per organisatie te gebruiken, is een Enterprise-licentie vereist.",
"idpDeletedDescription": "Identity provider succesvol verwijderd",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Weet u zeker dat u de identiteitsprovider permanent wilt verwijderen?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Einde van volgend jaar",
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
"licenseRequiredToUse": "Een Enterprise-licentie is vereist om deze functie te gebruiken.",
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken.",
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Certificaat Resolver",
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
"selectCertResolver": "Certificaat Resolver selecteren",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Zwiększ liczbę witryn",
"idpManage": "Zarządzaj dostawcami tożsamości",
"idpManageDescription": "Wyświetl i zarządzaj dostawcami tożsamości w systemie",
"idpGlobalModeBanner": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używa globalnych IdP (współdzielonych ze wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym <adminPanelLink></adminPanelLink>. Aby włączyć IdP na organizację, edytuj konfigurację serwera i ustaw tryb IdP na org. <configDocsLink>Zobacz dokumentację</configDocsLink>. Jeśli chcesz nadal używać globalnych IdP i sprawić, że zniknie to z ustawień organizacji, wyraźnie ustaw tryb globalny w konfiguracji.",
"idpGlobalModeBannerUpgradeRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym <adminPanelLink></adminPanelLink>. Aby korzystać z dostawców tożsamości na organizację, musisz zaktualizować do edycji Enterprise.",
"idpGlobalModeBannerLicenseRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym <adminPanelLink></adminPanelLink>. Aby korzystać z dostawców tożsamości na organizację, wymagana jest licencja Enterprise.",
"idpDeletedDescription": "Dostawca tożsamości został pomyślnie usunięty",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Czy na pewno chcesz trwale usunąć dostawcę tożsamości?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Koniec następnego roku",
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
"licenseRequiredToUse": "Licencja Enterprise jest wymagana do korzystania z tej funkcji.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji.",
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Rozwiązywanie certyfikatów",
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
"selectCertResolver": "Wybierz Resolver certyfikatów",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Aumentar contagem de sites",
"idpManage": "Gerir Provedores de Identidade",
"idpManageDescription": "Visualizar e gerir provedores de identidade no sistema",
"idpGlobalModeBanner": "Provedores de identidade (Pds) por organização estão desabilitados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie IdPs no painel <adminPanelLink>admin</adminPanelLink>. Para habilitar IdPs por organização, edite a configuração do servidor e defina o modo IdP como org. <configDocsLink>Veja a documentação</configDocsLink>. Se quiser continuar usando IdPs globais e fazer isso desaparecer das configurações da organização, defina explicitamente o modo como global na configuração.",
"idpGlobalModeBannerUpgradeRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no <adminPanelLink>painel administrativo</adminPanelLink>. Para usar provedores de identidade por organização, você deve atualizar para a edição Enterprise.",
"idpGlobalModeBannerLicenseRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no <adminPanelLink>painel administrativo</adminPanelLink>. Para usar provedores de identidade por organização, é necessário uma licença Enterprise.",
"idpDeletedDescription": "Provedor de identidade eliminado com sucesso",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Tem certeza que deseja eliminar permanentemente o provedor de identidade?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Fim do ano seguinte",
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
"licenseRequiredToUse": "É necessária uma licença empresarial para usar esse recurso.",
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso.",
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> é necessária para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Resolvedor de Certificado",
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
"selectCertResolver": "Selecionar solucionador de certificado",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Увеличить количество сайтов",
"idpManage": "Управление поставщиками удостоверений",
"idpManageDescription": "Просмотр и управление поставщиками удостоверений в системе",
"idpGlobalModeBanner": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в <adminPanelLink>админ-панели</adminPanelLink>. Чтобы включить IdP для каждой организации, отредактируйте конфигурацию сервера и установите режим IdP в org. <configDocsLink>См. документацию</configDocsLink>. Если вы хотите продолжать использовать глобальные IdP и скрыть это из настроек организации, явно установите режим в глобальном конфиге.",
"idpGlobalModeBannerUpgradeRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в <adminPanelLink>админ-панели</adminPanelLink>. Чтобы использовать поставщиков удостоверений для каждой организации, необходимо обновить систему до версии Enterprise.",
"idpGlobalModeBannerLicenseRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в <adminPanelLink>админ-панели</adminPanelLink>. Для использования поставщиков удостоверений на организацию требуется лицензия Enterprise.",
"idpDeletedDescription": "Поставщик удостоверений успешно удалён",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Вы уверены, что хотите навсегда удалить поставщика удостоверений?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Конец следующего года",
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
"licenseRequiredToUse": "Для использования этой функции требуется лицензия предприятия.",
"ossEnterpriseEditionRequired": "Для использования этой функции требуется корпоративная версия <enterpriseEditionLink></enterpriseEditionLink>.",
"licenseRequiredToUse": "Лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"ossEnterpriseEditionRequired": "Для использования этой функции требуется <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink>. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Резольвер сертификата",
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
"selectCertResolver": "Выберите резолвер сертификата",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "Site sayısını artır",
"idpManage": "Kimlik Sağlayıcılarını Yönet",
"idpManageDescription": "Sistem içindeki kimlik sağlayıcıları görün ve yönetin",
"idpGlobalModeBanner": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri <adminPanelLink> yönetici panelinde </adminPanelLink>yönetin. Örgüt başına IdP'leri etkinleştirmek için, sunucu yapılandırmasını düzenleyin ve IdP modunu 'org' olarak ayarlayın. <configDocsLink> Belgeleri inceleyin </configDocsLink>. Küresel IdP'leri kullanmaya devam etmek istiyorsanız ve bunun örgüt ayarlarından kaybolmasını istiyorsanız, yapılandırmada modu otomatik olarak 'global' olarak ayarlayın.",
"idpGlobalModeBannerUpgradeRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri <adminPanelLink> yönetici panelinde </adminPanelLink>yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için, Enterprise sürümüne yükseltmeniz gerekmektedir.",
"idpGlobalModeBannerLicenseRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri <adminPanelLink> yönetici panelinde </adminPanelLink>yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için Enterprise lisansı gereklidir.",
"idpDeletedDescription": "Kimlik sağlayıcı başarıyla silindi",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "Kimlik sağlayıcısını kalıcı olarak silmek istediğinizden emin misiniz?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
"licenseRequiredToUse": "Bu özelliği kullanmak için bir kurumsal lisans gereklidir.",
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Kurumsal Sürüm</enterpriseEditionLink> gereklidir.",
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
"certResolver": "Sertifika Çözücü",
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
"selectCertResolver": "Sertifika Çözücü Seçin",

View File

@@ -791,6 +791,9 @@
"sitestCountIncrease": "增加站点数量",
"idpManage": "管理身份提供商",
"idpManageDescription": "查看和管理系统中的身份提供商",
"idpGlobalModeBanner": "此服务器上禁用了每个组织的身份提供商(Idps)。 它正在使用全局IdP(所有组织共享)。在 <adminPanelLink>管理面板</adminPanelLink>中管理全局IdP。 要启用每个组织的 IdP请编辑服务器配置并将 IdP 模式设置为 org。 <configDocsLink>请参阅文档</configDocsLink>。 如果您想要继续使用全局IdP并使其从组织设置中消失请在配置中将模式设置为全局模式。",
"idpGlobalModeBannerUpgradeRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 <adminPanelLink>管理面板</adminPanelLink>管理全局身份。要使用每个组织的身份提供者,您必须升级到企业版本。",
"idpGlobalModeBannerLicenseRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 <adminPanelLink>管理面板</adminPanelLink>管理全局身份。要使用每个组织的身份提供者,需要企业许可证。",
"idpDeletedDescription": "身份提供商删除成功",
"idpOidc": "OAuth2/OIDC",
"idpQuestionRemove": "您确定要永久删除身份提供者吗?",
@@ -2280,8 +2283,8 @@
"logRetentionEndOfFollowingYear": "下一年结束",
"actionLogsDescription": "查看此机构执行的操作历史",
"accessLogsDescription": "查看此机构资源的访问认证请求",
"licenseRequiredToUse": "需要企业许可才能使用此功能。",
"ossEnterpriseEditionRequired": "需要 <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 才能使用此功能。",
"licenseRequiredToUse": "需要 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> 许可才能使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 需要使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
"certResolver": "证书解决器",
"certResolverDescription": "选择用于此资源的证书解析器。",
"selectCertResolver": "选择证书解析",

1558
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,10 @@
"scripts": {
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
"db:push": "npx tsx server/db/migrate.ts",
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
"db:clear-migrations": "rm -rf server/migrations",
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
@@ -36,7 +33,7 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.0",
"@aws-sdk/client-s3": "3.971.0",
"@aws-sdk/client-s3": "3.990.0",
"@faker-js/faker": "10.2.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",

View File

@@ -56,15 +56,15 @@ Ensure drizzle-kit is installed.
You must have a connection string in your config file, as shown above.
```bash
npm run db:pg:generate
npm run db:pg:push
npm run db:generate
npm run db:push
```
### SQLite
```bash
npm run db:sqlite:generate
npm run db:sqlite:push
npm run db:generate
npm run db:push
```
## Build Time

3
server/db/migrate.ts Normal file
View File

@@ -0,0 +1,3 @@
import { runMigrations } from "./";
await runMigrations();

View File

@@ -1,3 +1,4 @@
export * from "./driver";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";

View File

@@ -4,7 +4,7 @@ import path from "path";
const migrationsFolder = path.join("server/migrations");
const runMigrations = async () => {
export const runMigrations = async () => {
console.log("Running migrations...");
try {
await migrate(db as any, {
@@ -17,5 +17,3 @@ const runMigrations = async () => {
process.exit(1);
}
};
runMigrations();

View File

@@ -142,7 +142,8 @@ export const resources = pgTable("resources", {
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
});
export const targets = pgTable("targets", {

View File

@@ -1,3 +1,4 @@
export * from "./driver";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";

View File

@@ -4,7 +4,7 @@ import path from "path";
const migrationsFolder = path.join("server/migrations");
const runMigrations = async () => {
export const runMigrations = async () => {
console.log("Running migrations...");
try {
migrate(db as any, {
@@ -16,5 +16,3 @@ const runMigrations = async () => {
process.exit(1);
}
};
runMigrations();

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

@@ -162,7 +162,8 @@ export const resources = sqliteTable("resources", {
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
});
export const targets = sqliteTable("targets", {

View File

@@ -15,10 +15,10 @@ export const sandboxLimitSet: LimitSet = {
};
export const freeLimitSet: LimitSet = {
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
};
export const tier1LimitSet: LimitSet = {

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.4";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

169
server/lib/deleteOrg.ts Normal file
View File

@@ -0,0 +1,169 @@
import {
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
domains,
olms,
orgDomains,
orgs,
resources,
sites
} from "@server/db";
import { newts, newtSessions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
olmsToTerminate: string[];
};
/**
* Deletes one organization and its related data. Returns ids for termination
* messages; caller should call sendTerminationMessages with the result.
* Throws if org not found.
*/
export async function deleteOrgById(
orgId: string
): Promise<DeleteOrgByIdResult> {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
);
}
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, orgId))
.limit(1);
const orgClients = await db
.select()
.from(clients)
.where(eq(clients.orgId, orgId));
const deletedNewtIds: string[] = [];
const olmsToTerminate: string[] = [];
await db.transaction(async (trx) => {
for (const site of orgSites) {
if (site.pubKey) {
if (site.type == "wireguard") {
await deletePeer(site.exitNodeId!, site.pubKey);
} else if (site.type == "newt") {
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
deletedNewtIds.push(deletedNewt.newtId);
await trx
.delete(newtSessions)
.where(
eq(newtSessions.newtId, deletedNewt.newtId)
);
}
}
}
logger.info(`Deleting site ${site.siteId}`);
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
const [olm] = await trx
.select()
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (olm) {
olmsToTerminate.push(olm.olmId);
}
logger.info(`Deleting client ${client.clientId}`);
await trx
.delete(clients)
.where(eq(clients.clientId, client.clientId));
await trx
.delete(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
);
await trx
.delete(clientSitesAssociationsCache)
.where(
eq(clientSitesAssociationsCache.clientId, client.clientId)
);
}
const allOrgDomains = await trx
.select()
.from(orgDomains)
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where(
and(
eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false)
)
);
const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId;
const orgCount = await trx
.select({ count: sql<number>`count(*)` })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
if (orgCount[0].count === 1) {
domainIdsToDelete.push(domainId);
}
}
if (domainIdsToDelete.length > 0) {
await trx
.delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete));
}
await trx.delete(resources).where(eq(resources.orgId, orgId));
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
return { deletedNewtIds, olmsToTerminate };
}
export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
for (const newtId of result.deletedNewtIds) {
sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch(
(error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
}
);
}
for (const olmId of result.olmsToTerminate) {
sendTerminateClient(
0,
OlmErrorCodes.TERMINATED_REKEYED,
olmId
).catch((error) => {
logger.error(
"Failed to send termination message to olm:",
error
);
});
}
}

View File

@@ -0,0 +1,18 @@
/**
* Normalizes a post-authentication path for safe use when building redirect URLs.
* Returns a path that starts with / and does not allow open redirects (no //, no :).
*/
export function normalizePostAuthPath(path: string | null | undefined): string | null {
if (path == null || typeof path !== "string") {
return null;
}
const trimmed = path.trim();
if (trimmed === "") {
return null;
}
// Reject protocol-relative (//) or scheme (:) to avoid open redirect
if (trimmed.includes("//") || trimmed.includes(":")) {
return null;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}

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

@@ -18,6 +18,113 @@ import logger from "@server/logger";
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
import { eq } from "drizzle-orm";
/**
* Get the maximum allowed retention days for a given tier
* Returns null for enterprise tier (unlimited)
*/
function getMaxRetentionDaysForTier(tier: Tier | null): number | null {
if (!tier) {
return 3; // Free tier
}
switch (tier) {
case "tier1":
return 7;
case "tier2":
return 30;
case "tier3":
return 90;
case "enterprise":
return null; // No limit
default:
return 3; // Default to free tier limit
}
}
/**
* Cap retention days to the maximum allowed for the given tier
*/
async function capRetentionDays(
orgId: string,
tier: Tier | null
): Promise<void> {
const maxRetentionDays = getMaxRetentionDaysForTier(tier);
// If there's no limit (enterprise tier), no capping needed
if (maxRetentionDays === null) {
logger.debug(
`No retention day limit for org ${orgId} on tier ${tier || "free"}`
);
return;
}
// Get current org settings
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) {
logger.warn(`Org ${orgId} not found when capping retention days`);
return;
}
const updates: Partial<typeof orgs.$inferInsert> = {};
let needsUpdate = false;
// Cap request log retention if it exceeds the limit
if (
org.settingsLogRetentionDaysRequest !== null &&
org.settingsLogRetentionDaysRequest > maxRetentionDays
) {
updates.settingsLogRetentionDaysRequest = maxRetentionDays;
needsUpdate = true;
logger.info(
`Capping request log retention from ${org.settingsLogRetentionDaysRequest} to ${maxRetentionDays} days for org ${orgId}`
);
}
// Cap access log retention if it exceeds the limit
if (
org.settingsLogRetentionDaysAccess !== null &&
org.settingsLogRetentionDaysAccess > maxRetentionDays
) {
updates.settingsLogRetentionDaysAccess = maxRetentionDays;
needsUpdate = true;
logger.info(
`Capping access log retention from ${org.settingsLogRetentionDaysAccess} to ${maxRetentionDays} days for org ${orgId}`
);
}
// Cap action log retention if it exceeds the limit
if (
org.settingsLogRetentionDaysAction !== null &&
org.settingsLogRetentionDaysAction > maxRetentionDays
) {
updates.settingsLogRetentionDaysAction = maxRetentionDays;
needsUpdate = true;
logger.info(
`Capping action log retention from ${org.settingsLogRetentionDaysAction} to ${maxRetentionDays} days for org ${orgId}`
);
}
// Apply updates if needed
if (needsUpdate) {
await db
.update(orgs)
.set(updates)
.where(eq(orgs.orgId, orgId));
logger.info(
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
);
} else {
logger.debug(
`No retention day capping needed for org ${orgId}`
);
}
}
export async function handleTierChange(
orgId: string,
newTier: SubscriptionType | null,
@@ -40,6 +147,9 @@ export async function handleTierChange(
logger.info(
`Org ${orgId} is reverting to free tier, disabling all paid features`
);
// Cap retention days to free tier limits
await capRetentionDays(orgId, null);
// Disable all features in the tier matrix
for (const [featureKey] of Object.entries(tierMatrix)) {
const feature = featureKey as TierFeature;
@@ -57,6 +167,9 @@ export async function handleTierChange(
// Get the tier (cast as Tier since we've ruled out "license" and null)
const tier = newTier as Tier;
// Cap retention days to the new tier's limits
await capRetentionDays(orgId, tier);
// Check each feature in the tier matrix
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
const feature = featureKey as TierFeature;

View File

@@ -113,7 +113,7 @@ export async function generateNewEnterpriseLicense(
}
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
const tierPrice = getLicensePriceSet()[tier]
const tierPrice = getLicensePriceSet()[tier];
const session = await stripe!.checkout.sessions.create({
client_reference_id: keyId.toString(),

View File

@@ -27,6 +27,8 @@ 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";
import { build } from "@server/build";
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
@@ -92,6 +94,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,
@@ -109,12 +123,14 @@ export async function createOrgOidcIdp(
let { autoProvision } = parsedBody.data;
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
}
}
const key = config.getRawConfig().server.secret!;

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,8 @@ 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";
import { build } from "@server/build";
const paramsSchema = z
.object({
@@ -97,6 +99,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,
@@ -114,12 +128,15 @@ export async function updateOrgOidcIdp(
let { autoProvision } = parsedBody.data;
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
if (build == "saas") {
// this is not paywalled with a ee license because this whole endpoint is restricted
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
}
}
// Check if IDP exists and is of type OIDC

View File

@@ -0,0 +1,228 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgs, userOrgs, users } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { verifySession } from "@server/auth/sessions/verifySession";
import {
invalidateSession,
createBlankSessionTokenCookie
} from "@server/auth/sessions/app";
import { verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import {
deleteOrgById,
sendTerminationMessages
} from "@server/lib/deleteOrg";
import { UserType } from "@server/types/UserTypes";
const deleteMyAccountBody = z.strictObject({
password: z.string().optional(),
code: z.string().optional()
});
export type DeleteMyAccountPreviewResponse = {
preview: true;
orgs: { orgId: string; name: string }[];
twoFactorEnabled: boolean;
};
export type DeleteMyAccountCodeRequestedResponse = {
codeRequested: true;
};
export type DeleteMyAccountSuccessResponse = {
success: true;
};
/**
* Self-service account deletion (saas only). Returns preview when no password;
* requires password and optional 2FA code to perform deletion. Uses shared
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
*/
export async function deleteMyAccount(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { user, session } = await verifySession(req);
if (!user || !session) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated")
);
}
if (user.serverAdmin) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admins cannot delete their account this way"
)
);
}
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Account deletion with password is only supported for internal users"
)
);
}
const parsed = deleteMyAccountBody.safeParse(req.body ?? {});
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { password, code } = parsed.data;
const userId = user.userId;
const ownedOrgsRows = await db
.select({
orgId: userOrgs.orgId
})
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.isOwner, true)
)
);
const orgIds = ownedOrgsRows.map((r) => r.orgId);
if (!password) {
const orgsWithNames =
orgIds.length > 0
? await db
.select({
orgId: orgs.orgId,
name: orgs.name
})
.from(orgs)
.where(inArray(orgs.orgId, orgIds))
: [];
return response<DeleteMyAccountPreviewResponse>(res, {
data: {
preview: true,
orgs: orgsWithNames.map((o) => ({
orgId: o.orgId,
name: o.name ?? ""
})),
twoFactorEnabled: user.twoFactorEnabled ?? false
},
success: true,
error: false,
message: "Preview",
status: HttpCode.OK
});
}
const validPassword = await verifyPassword(
password,
user.passwordHash!
);
if (!validPassword) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid password")
);
}
if (user.twoFactorEnabled) {
if (!code) {
return response<DeleteMyAccountCodeRequestedResponse>(res, {
data: { codeRequested: true },
success: true,
error: false,
message: "Two-factor code required",
status: HttpCode.ACCEPTED
});
}
const validOTP = await verifyTotpCode(
code,
user.twoFactorSecret!,
user.userId
);
if (!validOTP) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The two-factor code you entered is incorrect"
)
);
}
}
const allDeletedNewtIds: string[] = [];
const allOlmsToTerminate: string[] = [];
for (const row of ownedOrgsRows) {
try {
const result = await deleteOrgById(row.orgId);
allDeletedNewtIds.push(...result.deletedNewtIds);
allOlmsToTerminate.push(...result.olmsToTerminate);
} catch (err) {
logger.error(
`Failed to delete org ${row.orgId} during account deletion`,
err
);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete organization"
)
);
}
}
sendTerminationMessages({
deletedNewtIds: allDeletedNewtIds,
olmsToTerminate: allOlmsToTerminate
});
await db.transaction(async (trx) => {
await trx.delete(users).where(eq(users.userId, userId));
await calculateUserClientsForOrgs(userId, trx);
});
try {
await invalidateSession(session.sessionId);
} catch (error) {
logger.error(
"Failed to invalidate session after account deletion",
error
);
}
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<DeleteMyAccountSuccessResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Account deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
);
}
}

View File

@@ -17,4 +17,5 @@ export * from "./securityKey";
export * from "./startDeviceWebAuth";
export * from "./verifyDeviceWebAuth";
export * from "./pollDeviceWebAuth";
export * from "./lookupUser";
export * from "./lookupUser";
export * from "./deleteMyAccount";

View File

@@ -797,7 +797,7 @@ async function notAllowed(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(
const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature
orgId,
tierMatrix.loginPageDomain
);
@@ -854,7 +854,7 @@ async function headerAuthChallenged(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain);
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature
if (subscribed) {
loginPage = await getOrgLoginPage(orgId);
}

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

@@ -1164,6 +1164,7 @@ authRouter.post(
auth.login
);
authRouter.post("/logout", auth.logout);
authRouter.post("/delete-my-account", auth.deleteMyAccount);
authRouter.post(
"/lookup-user",
rateLimit({

View File

@@ -1,28 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
domains,
olms,
orgDomains,
resources
} from "@server/db";
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "../gerbil/peers";
import { OpenAPITags, registry } from "@server/openApi";
import { OlmErrorCodes } from "../olm/error";
import { sendTerminateClient } from "../client/terminate";
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
const deleteOrgSchema = z.strictObject({
orgId: z.string()
@@ -56,170 +40,9 @@ export async function deleteOrg(
)
);
}
const { orgId } = parsedParams.data;
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
// we need to handle deleting each site
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, orgId))
.limit(1);
const orgClients = await db
.select()
.from(clients)
.where(eq(clients.orgId, orgId));
const deletedNewtIds: string[] = [];
const olmsToTerminate: string[] = [];
await db.transaction(async (trx) => {
for (const site of orgSites) {
if (site.pubKey) {
if (site.type == "wireguard") {
await deletePeer(site.exitNodeId!, site.pubKey);
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
deletedNewtIds.push(deletedNewt.newtId);
// delete all of the sessions for the newt
await trx
.delete(newtSessions)
.where(
eq(newtSessions.newtId, deletedNewt.newtId)
);
}
}
}
logger.info(`Deleting site ${site.siteId}`);
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
const [olm] = await trx
.select()
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (olm) {
olmsToTerminate.push(olm.olmId);
}
logger.info(`Deleting client ${client.clientId}`);
await trx
.delete(clients)
.where(eq(clients.clientId, client.clientId));
// also delete the associations
await trx
.delete(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
);
await trx
.delete(clientSitesAssociationsCache)
.where(
eq(
clientSitesAssociationsCache.clientId,
client.clientId
)
);
}
const allOrgDomains = await trx
.select()
.from(orgDomains)
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where(
and(
eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false)
)
);
// For each domain, check if it belongs to multiple organizations
const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId;
// Count how many organizations this domain belongs to
const orgCount = await trx
.select({ count: sql<number>`count(*)` })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
if (orgCount[0].count === 1) {
domainIdsToDelete.push(domainId);
}
}
// Delete domains that belong exclusively to this organization
if (domainIdsToDelete.length > 0) {
await trx
.delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete));
}
// Delete resources
await trx.delete(resources).where(eq(resources.orgId, orgId));
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
// Send termination messages outside of transaction to prevent blocking
for (const newtId of deletedNewtIds) {
const payload = {
type: `newt/wg/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(newtId, payload).catch((error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
});
}
for (const olmId of olmsToTerminate) {
sendTerminateClient(
0, // clientId not needed since we're passing olmId
OlmErrorCodes.TERMINATED_REKEYED,
olmId
).catch((error) => {
logger.error(
"Failed to send termination message to olm:",
error
);
});
}
const result = await deleteOrgById(orgId);
sendTerminationMessages(result);
return response(res, {
data: null,
success: true,
@@ -228,6 +51,9 @@ export async function deleteOrg(
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
logger.error(error);
return next(
createHttpError(

View File

@@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
import config from "@server/lib/config";
import stoi from "@server/lib/stoi";
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
const authWithAccessTokenBodySchema = z.strictObject({
accessToken: z.string(),
@@ -164,10 +165,16 @@ export async function authWithAccessToken(
requestIp: req.ip
});
let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
const postAuthPath = normalizePostAuthPath(resource.postAuthPath);
if (postAuthPath) {
redirectUrl = redirectUrl + postAuthPath;
}
return response<AuthWithAccessTokenResponse>(res, {
data: {
session: token,
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
redirectUrl
},
success: true,
error: false,

View File

@@ -36,7 +36,8 @@ const createHttpResourceSchema = z
http: z.boolean(),
protocol: z.enum(["tcp", "udp"]),
domainId: z.string(),
stickySession: z.boolean().optional()
stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().optional()
})
.refine(
(data) => {
@@ -188,7 +189,7 @@ async function createHttpResource(
);
}
const { name, domainId } = parsedBody.data;
const { name, domainId, postAuthPath } = parsedBody.data;
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
@@ -255,7 +256,8 @@ async function createHttpResource(
http: true,
protocol: "tcp",
ssl: true,
stickySession: stickySession
stickySession: stickySession,
postAuthPath: postAuthPath
})
.returning();

View File

@@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = {
whitelist: boolean;
skipToIdpId: number | null;
orgId: string;
postAuthPath: string | null;
};
export async function getResourceAuthInfo(
@@ -147,7 +148,8 @@ export async function getResourceAuthInfo(
url,
whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId
orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null
},
success: true,
error: false,

View File

@@ -55,7 +55,8 @@ const updateHttpResourceBodySchema = z
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional()
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"

1
server/setup/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
migrations.ts

View File

@@ -1,162 +0,0 @@
#! /usr/bin/env node
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { db } from "../db/pg";
import semver from "semver";
import { versionMigrations } from "../db/pg";
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import path from "path";
import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0";
import m4 from "./scriptsPg/1.9.0";
import m5 from "./scriptsPg/1.10.0";
import m6 from "./scriptsPg/1.10.2";
import m7 from "./scriptsPg/1.11.0";
import m8 from "./scriptsPg/1.11.1";
import m9 from "./scriptsPg/1.12.0";
import m10 from "./scriptsPg/1.13.0";
import m11 from "./scriptsPg/1.14.0";
import m12 from "./scriptsPg/1.15.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
// Define the migration list with versions and their corresponding functions
const migrations = [
{ version: "1.6.0", run: m1 },
{ version: "1.7.0", run: m2 },
{ version: "1.8.0", run: m3 },
{ version: "1.9.0", run: m4 },
{ version: "1.10.0", run: m5 },
{ version: "1.10.2", run: m6 },
{ version: "1.11.0", run: m7 },
{ version: "1.11.1", run: m8 },
{ version: "1.12.0", run: m9 },
{ version: "1.13.0", run: m10 },
{ version: "1.14.0", run: m11 },
{ version: "1.15.0", run: m12 }
// Add new migrations here as they are created
] as {
version: string;
run: () => Promise<void>;
}[];
await run();
async function run() {
// run the migrations
await runMigrations();
}
export async function runMigrations() {
if (process.env.DISABLE_MIGRATIONS) {
console.log("Migrations are disabled. Skipping...");
return;
}
try {
const appVersion = APP_VERSION;
// determine if the migrations table exists
const exists = await db
.select()
.from(versionMigrations)
.limit(1)
.execute()
.then((res) => res.length > 0)
.catch(() => false);
if (exists) {
console.log("Migrations table exists, running scripts...");
await executeScripts();
} else {
console.log("Migrations table does not exist, creating it...");
console.log("Running migrations...");
try {
await migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
);
}
}
async function executeScripts() {
try {
// Get the last executed version from the database
const lastExecuted = await db.select().from(versionMigrations);
// Filter and sort migrations
const pendingMigrations = lastExecuted
.map((m) => m)
.sort((a, b) => semver.compare(b.version, a.version));
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const migrationsToRun = migrations.filter((migration) =>
semver.gt(migration.version, startVersion)
);
console.log(
"Migrations to run:",
migrationsToRun.map((m) => m.version).join(", ")
);
// Run migrations in order
for (const migration of migrationsToRun) {
console.log(`Running migration ${migration.version}`);
try {
await migration.run();
// Update version in database
await db
.insert(versionMigrations)
.values({
version: migration.version,
executedAt: Date.now()
})
.execute();
console.log(
`Successfully completed migration ${migration.version}`
);
} catch (e) {
if (
e instanceof Error &&
typeof (e as any).code === "string" &&
(e as any).code === "23505"
) {
console.error("Migration has already run! Skipping...");
continue; // or return, depending on context
}
console.error(
`Failed to run migration ${migration.version}:`,
e
);
throw e;
}
}
console.log("All migrations completed successfully");
} catch (error) {
console.error("Migration process failed:", error);
throw error;
}
}

View File

@@ -17,6 +17,8 @@ import m9 from "./scriptsPg/1.12.0";
import m10 from "./scriptsPg/1.13.0";
import m11 from "./scriptsPg/1.14.0";
import m12 from "./scriptsPg/1.15.0";
import m13 from "./scriptsPg/1.15.3";
import m14 from "./scriptsPg/1.15.4";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -34,7 +36,9 @@ const migrations = [
{ version: "1.12.0", run: m9 },
{ version: "1.13.0", run: m10 },
{ version: "1.14.0", run: m11 },
{ version: "1.15.0", run: m12 }
{ version: "1.15.0", run: m12 },
{ version: "1.15.3", run: m13 },
{ version: "1.15.4", run: m14 }
// Add new migrations here as they are created
] as {
version: string;

View File

@@ -35,6 +35,8 @@ import m30 from "./scriptsSqlite/1.12.0";
import m31 from "./scriptsSqlite/1.13.0";
import m32 from "./scriptsSqlite/1.14.0";
import m33 from "./scriptsSqlite/1.15.0";
import m34 from "./scriptsSqlite/1.15.3";
import m35 from "./scriptsSqlite/1.15.4";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -68,7 +70,9 @@ const migrations = [
{ version: "1.12.0", run: m30 },
{ version: "1.13.0", run: m31 },
{ version: "1.14.0", run: m32 },
{ version: "1.15.0", run: m33 }
{ version: "1.15.0", run: m33 },
{ version: "1.15.3", run: m34 },
{ version: "1.15.4", run: m35 }
// Add new migrations here as they are created
] as const;

View File

@@ -0,0 +1,39 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts";
const version = "1.15.3";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(
sql`ALTER TABLE "limits" ADD COLUMN "override" boolean DEFAULT false;`
);
await db.execute(
sql`ALTER TABLE "subscriptionItems" ADD COLUMN "stripeSubscriptionItemId" varchar(255);`
);
await db.execute(
sql`ALTER TABLE "subscriptionItems" ADD COLUMN "featureId" varchar(255);`
);
await db.execute(
sql`ALTER TABLE "subscriptions" ADD COLUMN "version" integer;`
);
await db.execute(
sql`ALTER TABLE "subscriptions" ADD COLUMN "type" varchar(50);`
);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,27 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts";
const version = "1.15.4";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(
sql`ALTER TABLE "resources" ADD COLUMN "postAuthPath" text;`
);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,29 @@
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.15.3";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
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();
})();
console.log(`Migrated database`);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,27 @@
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.15.4";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.transaction(() => {
db.prepare(
`ALTER TABLE 'resources' ADD 'postAuthPath' text;`
).run();
})();
console.log(`Migrated database`);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -61,7 +61,7 @@ import {
import { FeatureId } from "@server/lib/billing/features";
// Plan tier definitions matching the mockup
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
type PlanOption = {
id: PlanId;
@@ -73,8 +73,8 @@ type PlanOption = {
const planOptions: PlanOption[] = [
{
id: "starter",
name: "Starter",
id: "basic",
name: "Basic",
price: "Free",
tierType: null
},
@@ -109,10 +109,10 @@ const planOptions: PlanOption[] = [
// Tier limits mapping derived from limit sets
const tierLimits: Record<
Tier | "starter",
Tier | "basic",
{ users: number; sites: number; domains: number; remoteNodes: number }
> = {
starter: {
basic: {
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
@@ -183,7 +183,7 @@ export default function BillingPage() {
// Confirmation dialog state
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingTier, setPendingTier] = useState<{
tier: Tier | "starter";
tier: Tier | "basic";
action: "upgrade" | "downgrade";
planName: string;
price: string;
@@ -402,8 +402,8 @@ export default function BillingPage() {
pendingTier.action === "upgrade" ||
pendingTier.action === "downgrade"
) {
// If downgrading to starter (free tier), go to Stripe portal
if (pendingTier.tier === "starter") {
// If downgrading to basic (free tier), go to Stripe portal
if (pendingTier.tier === "basic") {
handleModifySubscription();
} else if (hasSubscription) {
handleChangeTier(pendingTier.tier);
@@ -417,7 +417,7 @@ export default function BillingPage() {
};
const showTierConfirmation = (
tier: Tier | "starter",
tier: Tier | "basic",
action: "upgrade" | "downgrade",
planName: string,
price: string
@@ -432,9 +432,9 @@ export default function BillingPage() {
// Get current plan ID from tier
const getCurrentPlanId = (): PlanId => {
if (!hasSubscription || !currentTier) return "starter";
if (!hasSubscription || !currentTier) return "basic";
const plan = planOptions.find((p) => p.tierType === currentTier);
return plan?.id || "starter";
return plan?.id || "basic";
};
const currentPlanId = getCurrentPlanId();
@@ -451,8 +451,8 @@ export default function BillingPage() {
}
if (plan.id === currentPlanId) {
// If it's the starter plan (starter with no subscription), show as current but disabled
if (plan.id === "starter" && !hasSubscription) {
// If it's the basic plan (basic with no subscription), show as current but disabled
if (plan.id === "basic" && !hasSubscription) {
return {
label: "Current Plan",
action: () => {},
@@ -484,10 +484,10 @@ export default function BillingPage() {
plan.name,
plan.price + (" " + plan.priceDetail || "")
);
} else if (plan.id === "starter") {
// Show confirmation for downgrading to starter (free tier)
} else if (plan.id === "basic") {
// Show confirmation for downgrading to basic (free tier)
showTierConfirmation(
"starter",
"basic",
"downgrade",
plan.name,
plan.price
@@ -566,7 +566,7 @@ export default function BillingPage() {
};
// Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
feature: string;
currentUsage: number;
newLimit: number;

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

@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@app/components/ui/button";
import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
import UserProfileCard from "@app/components/UserProfileCard";
import { ArrowLeft } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
type DeleteAccountClientProps = {
displayName: string;
};
export default function DeleteAccountClient({
displayName
}: DeleteAccountClientProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDialogOpen, setIsDialogOpen] = useState(false);
function handleUseDifferentAccount() {
api.post("/auth/logout")
.catch((e) => {
console.error(t("logoutError"), e);
toast({
title: t("logoutError"),
description: formatAxiosError(e, t("logoutError"))
});
})
.then(() => {
router.push(
"/auth/login?internal_redirect=/auth/delete-account"
);
router.refresh();
});
}
return (
<div className="space-y-6">
<UserProfileCard
identifier={displayName}
description={t("signingAs")}
onUseDifferentAccount={handleUseDifferentAccount}
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
/>
<p className="text-sm text-muted-foreground">
{t("deleteAccountDescription")}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("back")}
</Button>
<Button
variant="destructive"
onClick={() => setIsDialogOpen(true)}
>
{t("deleteAccountButton")}
</Button>
</div>
<DeleteAccountConfirmDialog
open={isDialogOpen}
setOpen={setIsDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { build } from "@server/build";
import { cache } from "react";
import DeleteAccountClient from "./DeleteAccountClient";
import { getTranslations } from "next-intl/server";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
export const dynamic = "force-dynamic";
export default async function DeleteAccountPage() {
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
if (!user) {
redirect("/auth/login");
}
const t = await getTranslations();
const displayName = getUserDisplayName({ user });
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">{t("deleteAccount")}</h1>
<DeleteAccountClient displayName={displayName} />
</div>
);
}

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

@@ -26,6 +26,7 @@ import type {
import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
export const dynamic = "force-dynamic";
@@ -108,6 +109,11 @@ export default async function ResourceAuthPage(props: {
} catch (e) {}
}
const normalizedPostAuthPath = normalizePostAuthPath(authInfo.postAuthPath);
if (normalizedPostAuthPath) {
redirectUrl = new URL(authInfo.url).origin + normalizedPostAuthPath;
}
const hasAuth =
authInfo.password ||
authInfo.pincode ||
@@ -202,7 +208,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

@@ -2,7 +2,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
type ApplyInternalRedirectProps = {
orgId: string;
@@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({
const router = useRouter();
useEffect(() => {
const path = consumeInternalRedirectPath();
if (path) {
router.replace(`/${orgId}${path}`);
const target = getInternalRedirectTarget(orgId);
if (target) {
router.replace(target);
}
}, [orgId, router]);

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

@@ -303,7 +303,7 @@ export default function CreateInternalResourceDialog({
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
(site) => site.type === "newt"
);
const form = useForm<FormData>({

View File

@@ -0,0 +1,414 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { Button } from "@app/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import type {
DeleteMyAccountPreviewResponse,
DeleteMyAccountCodeRequestedResponse,
DeleteMyAccountSuccessResponse
} from "@server/routers/auth/deleteMyAccount";
import { AxiosResponse } from "axios";
type DeleteAccountConfirmDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
export default function DeleteAccountConfirmDialog({
open,
setOpen
}: DeleteAccountConfirmDialogProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const passwordSchema = useMemo(
() =>
z.object({
password: z.string().min(1, { message: t("passwordRequired") })
}),
[t]
);
const codeSchema = useMemo(
() =>
z.object({
code: z.string().length(6, { message: t("pincodeInvalid") })
}),
[t]
);
const [step, setStep] = useState<0 | 1 | 2>(0);
const [loading, setLoading] = useState(false);
const [loadingPreview, setLoadingPreview] = useState(false);
const [preview, setPreview] =
useState<DeleteMyAccountPreviewResponse | null>(null);
const [passwordValue, setPasswordValue] = useState("");
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
resolver: zodResolver(passwordSchema),
defaultValues: { password: "" }
});
const codeForm = useForm<z.infer<typeof codeSchema>>({
resolver: zodResolver(codeSchema),
defaultValues: { code: "" }
});
useEffect(() => {
if (open && step === 0 && !preview) {
setLoadingPreview(true);
api.post<AxiosResponse<DeleteMyAccountPreviewResponse>>(
"/auth/delete-my-account",
{}
)
.then((res) => {
if (res.data?.data?.preview) {
setPreview(res.data.data);
}
})
.catch((err) => {
toast({
variant: "destructive",
title: t("deleteAccountError"),
description: formatAxiosError(
err,
t("deleteAccountError")
)
});
setOpen(false);
})
.finally(() => setLoadingPreview(false));
}
}, [open, step, preview, api, setOpen, t]);
function reset() {
setStep(0);
setPreview(null);
setPasswordValue("");
passwordForm.reset();
codeForm.reset();
}
async function handleContinueToPassword() {
setStep(1);
}
async function handlePasswordSubmit(
values: z.infer<typeof passwordSchema>
) {
setLoading(true);
setPasswordValue(values.password);
try {
const res = await api.post<
| AxiosResponse<DeleteMyAccountCodeRequestedResponse>
| AxiosResponse<DeleteMyAccountSuccessResponse>
>("/auth/delete-my-account", { password: values.password });
const data = res.data?.data;
if (data && "codeRequested" in data && data.codeRequested) {
setStep(2);
} else if (data && "success" in data && data.success) {
toast({
title: t("deleteAccountSuccess"),
description: t("deleteAccountSuccessMessage")
});
setOpen(false);
reset();
router.push("/auth/login");
router.refresh();
}
} catch (err) {
toast({
variant: "destructive",
title: t("deleteAccountError"),
description: formatAxiosError(err, t("deleteAccountError"))
});
} finally {
setLoading(false);
}
}
async function handleCodeSubmit(values: z.infer<typeof codeSchema>) {
setLoading(true);
try {
const res = await api.post<
AxiosResponse<DeleteMyAccountSuccessResponse>
>("/auth/delete-my-account", {
password: passwordValue,
code: values.code
});
if (res.data?.data?.success) {
toast({
title: t("deleteAccountSuccess"),
description: t("deleteAccountSuccessMessage")
});
setOpen(false);
reset();
router.push("/auth/login");
router.refresh();
}
} catch (err) {
toast({
variant: "destructive",
title: t("deleteAccountError"),
description: formatAxiosError(err, t("deleteAccountError"))
});
} finally {
setLoading(false);
}
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("deleteAccountConfirmTitle")}
</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
{step === 0 && (
<>
{loadingPreview ? (
<p className="text-sm text-muted-foreground">
{t("loading")}...
</p>
) : preview ? (
<>
<p className="text-sm text-muted-foreground">
{t("deleteAccountConfirmMessage")}
</p>
<div className="rounded-md bg-muted p-3 space-y-2">
<p className="text-sm font-medium">
{t(
"deleteAccountPreviewAccount"
)}
</p>
{preview.orgs.length > 0 && (
<>
<p className="text-sm font-medium mt-2">
{t(
"deleteAccountPreviewOrgs"
)}
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{preview.orgs.map(
(org) => (
<li
key={
org.orgId
}
>
{org.name ||
org.orgId}
</li>
)
)}
</ul>
</>
)}
</div>
<p className="text-sm font-bold text-destructive">
{t("cannotbeUndone")}
</p>
</>
) : null}
</>
)}
{step === 1 && (
<Form {...passwordForm}>
<form
id="delete-account-password-form"
onSubmit={passwordForm.handleSubmit(
handlePasswordSubmit
)}
className="space-y-4"
>
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground">
{t("otpAuthDescription")}
</p>
</div>
<Form {...codeForm}>
<form
id="delete-account-code-form"
onSubmit={codeForm.handleSubmit(
handleCodeSubmit
)}
className="space-y-4"
>
<FormField
control={codeForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(
value: string
) => {
field.onChange(
value
);
}}
>
<InputOTPGroup>
<InputOTPSlot
index={
0
}
/>
<InputOTPSlot
index={
1
}
/>
<InputOTPSlot
index={
2
}
/>
<InputOTPSlot
index={
3
}
/>
<InputOTPSlot
index={
4
}
/>
<InputOTPSlot
index={
5
}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{step === 0 && preview && !loadingPreview && (
<Button
variant="destructive"
onClick={handleContinueToPassword}
>
{t("continue")}
</Button>
)}
{step === 1 && (
<Button
variant="destructive"
type="submit"
form="delete-account-password-form"
loading={loading}
disabled={loading}
>
{t("deleteAccountButton")}
</Button>
)}
{step === 2 && (
<Button
variant="destructive"
type="submit"
form="delete-account-code-form"
loading={loading}
disabled={loading}
>
{t("deleteAccountButton")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -397,7 +397,7 @@ export default function EditInternalResourceDialog({
);
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
(site) => site.type === "newt"
);
const form = useForm<FormData>({

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

@@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
</Button>
</Link>
<Link
href="https://docs.pangolin.net/manage/clients/install-client#docker"
href="https://docs.pangolin.net/manage/clients/install-client#docker-pangolin-cli"
target="_blank"
rel="noopener noreferrer"
>

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

@@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { build } from "@server/build";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
@@ -187,6 +189,20 @@ export default function ProfileIcon() {
<DropdownMenuSeparator />
<LocaleSwitcher />
<DropdownMenuSeparator />
{user?.type === UserType.Internal && !user?.serverAdmin && (
<>
<DropdownMenuItem asChild>
<Link
href="/auth/delete-account"
className="flex cursor-pointer items-center"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("deleteAccount")}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>{t("logout")}</span>

View File

@@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
useEffect(() => {
try {
const target = getInternalRedirectTarget(targetOrgId);
const target =
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
router.replace(target);
} catch {
router.replace(`/${targetOrgId}`);

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

@@ -18,11 +18,11 @@ export type CommandItem = string | { title: string; command: string };
const PLATFORMS = [
"unix",
"windows",
"docker",
"kubernetes",
"podman",
"nixos"
"nixos",
"windows"
] as const;
type Platform = (typeof PLATFORMS)[number];

View File

@@ -14,7 +14,7 @@ import { Button } from "./ui/button";
export type CommandItem = string | { title: string; command: string };
const PLATFORMS = ["unix", "windows", "docker"] as const;
const PLATFORMS = ["unix", "docker", "windows"] as const;
type Platform = (typeof PLATFORMS)[number];
@@ -43,7 +43,7 @@ export function OlmInstallCommands({
All: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
},
{
title: t("run"),
@@ -51,24 +51,12 @@ export function OlmInstallCommands({
}
]
},
windows: {
x64: [
{
title: t("install"),
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
docker: {
"Docker Compose": [
`services:
olm:
image: fosrl/olm
container_name: olm
pangolin-cli:
image: fosrl/pangolin-cli
container_name: pangolin-cli
restart: unless-stopped
network_mode: host
cap_add:
@@ -77,11 +65,24 @@ export function OlmInstallCommands({
- /dev/net/tun:/dev/net/tun
environment:
- PANGOLIN_ENDPOINT=${endpoint}
- OLM_ID=${id}
- OLM_SECRET=${secret}`
- CLIENT_ID=${id}
- CLIENT_SECRET=${secret}`
],
"Docker Run": [
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/pangolin-cli up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
]
},
windows: {
x64: [
{
title: t("install"),
command: `# Download and run the installer to install Olm first\n
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
}
};

View File

@@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null {
}
/**
* Returns the full redirect target for an org: either `/${orgId}` or
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the
* stored value.
* Returns the full redirect target if a valid internal_redirect was stored
* (consumes the stored value). Returns null if none was stored or expired.
* Paths starting with /auth/ are returned as-is; others are prefixed with orgId.
*/
export function getInternalRedirectTarget(orgId: string): string {
export function getInternalRedirectTarget(orgId: string): string | null {
const path = consumeInternalRedirectPath();
return path ? `/${orgId}${path}` : `/${orgId}`;
if (!path) return null;
return path.startsWith("/auth/") ? path : `/${orgId}${path}`;
}

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: {