Compare commits

...

166 commits

Author SHA1 Message Date
miloschwartz
cbe1e4decb
fix 1.10.1 migration script 2025-09-20 15:06:27 +02:00
miloschwartz
17e3568b0d
fix installer 2025-09-20 15:06:27 +02:00
miloschwartz
1f827d8ecf
migrate siteId on targets table to delete on cascade 2025-09-20 15:06:27 +02:00
Tim
ee5bfb96c7
Update nl-NL.json
I think the file was accidentally reverted to the version that contained errors. The errors that were in that version have been updated again.
2025-09-20 15:06:26 +02:00
dependabot[bot]
2c3f44891e
Bump the dev-patch-updates group with 2 updates
Bumps the dev-patch-updates group with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [esbuild](https://github.com/evanw/esbuild).


Updates `@types/node` from 24.5.0 to 24.5.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `esbuild` from 0.25.9 to 0.25.10
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.9...v0.25.10)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: esbuild
  dependency-version: 0.25.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:06:26 +02:00
cku-heise
35ae064ce9
Update de-DE.json
"Spielpfad" is literal, but wrong translation (not a word in German). "Unterverzeichnis" would be the best approximation of the UI label here.
2025-09-20 15:06:26 +02:00
Barnabé Havard
91e0accc2a
Remove customHeadersDescription from fr-FR.json
Removed translation that doesn't appear anywhere
2025-09-20 15:06:26 +02:00
Barnabé Havard
63010c7889
Fix typo in French translation for 'dataIn' 2025-09-20 15:06:25 +02:00
Barnabé Havard
0ecfde982d
Update French translations in fr-FR.json 2025-09-20 15:06:25 +02:00
Owen Schwartz
c9fd050673
New translations en-us.json (Dutch) 2025-09-20 15:06:25 +02:00
Owen Schwartz
0ea0b241c1
New translations en-us.json (German) 2025-09-20 15:06:25 +02:00
Owen
bf4de08896
Quiet debug logs 2025-09-20 15:06:24 +02:00
miloschwartz
3e7712d7fd
fix type and fix redirect to resource niceId on create 2025-09-20 15:06:24 +02:00
dependabot[bot]
c206bd120d
Bump the dev-minor-updates group with 2 updates
Bumps the dev-minor-updates group with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@types/node` from 24.4.0 to 24.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `typescript-eslint` from 8.43.0 to 8.44.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.44.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:05:54 +02:00
dependabot[bot]
4e55368d83
Bump @dotenvx/dotenvx in the dev-patch-updates group
Bumps the dev-patch-updates group with 1 update: [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx).


Updates `@dotenvx/dotenvx` from 1.49.0 to 1.49.1
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.49.0...v1.49.1)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.49.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:05:54 +02:00
dependabot[bot]
38db3f4180
Bump next-intl from 4.3.8 to 4.3.9 in the prod-patch-updates group
Bumps the prod-patch-updates group with 1 update: [next-intl](https://github.com/amannn/next-intl).


Updates `next-intl` from 4.3.8 to 4.3.9
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.3.8...v4.3.9)

---
updated-dependencies:
- dependency-name: next-intl
  dependency-version: 4.3.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:05:54 +02:00
Tim
a450521714
Update nl-NL.json
It currently contains several errors. I believe I've fixed the major ones now, but there are probably still things I've overlooked.

Incidentally, there's no option to edit the main headings for General, Access Control, and Organization. This also applies to Manage Clients (beta) page.
2025-09-20 15:05:54 +02:00
Oliver Antwerpen
aba894eedf
Update de-DE.json
Corrected different translations to real german
2025-09-20 15:05:53 +02:00
miloschwartz
bcf4523689
ask for container type in crowdsec installer 2025-09-20 15:05:53 +02:00
miloschwartz
b1255ddb1e
fix cant create oidc user closes #1472 2025-09-20 15:05:53 +02:00
miloschwartz
5f43268b88
print failed crowdsec install error 2025-09-20 15:05:53 +02:00
Pallavi
7a17518054
resource links from id to niceId 2025-09-20 15:05:52 +02:00
Owen
8992e99c07
Make sure to default the match 2025-09-20 15:05:52 +02:00
miloschwartz
bf95b0a659
migrate autoProvisioned on user based on idp autoProvision 2025-09-20 15:05:52 +02:00
Owen
4b824f653f
Filter out duplicates 2025-09-20 15:05:52 +02:00
Owen
edf1cea738
Make sure to allow targets only 2025-09-20 15:05:51 +02:00
Owen Schwartz
5d87e7371a
New translations en-us.json (Norwegian Bokmal) 2025-09-20 15:05:51 +02:00
Owen Schwartz
7223761c43
New translations en-us.json (Chinese Simplified) 2025-09-20 15:05:51 +02:00
Owen Schwartz
0568e74821
New translations en-us.json (Turkish) 2025-09-20 15:05:51 +02:00
Owen Schwartz
87c0bd7717
New translations en-us.json (Russian) 2025-09-20 15:05:50 +02:00
Owen Schwartz
d916dd802e
New translations en-us.json (Portuguese) 2025-09-20 15:05:50 +02:00
Owen Schwartz
94a7aae289
New translations en-us.json (Polish) 2025-09-20 15:05:50 +02:00
Owen Schwartz
311df4cb0f
New translations en-us.json (Dutch) 2025-09-20 15:05:50 +02:00
Owen Schwartz
52bdfd2506
New translations en-us.json (Korean) 2025-09-20 15:05:49 +02:00
Owen Schwartz
a60038b79b
New translations en-us.json (Italian) 2025-09-20 15:05:49 +02:00
Owen Schwartz
8aa92176cb
New translations en-us.json (German) 2025-09-20 15:05:49 +02:00
Owen Schwartz
5ba9bcd7ba
New translations en-us.json (Czech) 2025-09-20 15:05:49 +02:00
Owen Schwartz
e55bc27992
New translations en-us.json (Bulgarian) 2025-09-20 15:05:48 +02:00
Owen Schwartz
fbd1534ed3
New translations en-us.json (Spanish) 2025-09-20 15:05:48 +02:00
Owen Schwartz
1f0de303cf
New translations en-us.json (French) 2025-09-20 15:05:48 +02:00
Owen
32f7d9f527
Remove toast 2025-09-20 15:05:48 +02:00
Pallavi
98cb271d26
minor fix to domain sanitize when create resources 2025-09-20 15:05:47 +02:00
Pallavi
4a35189c18
fix enter key reload issue 2025-09-20 15:05:47 +02:00
Pallavi
476cd915ab
Enter key handling & hostname field reset in resource create 2025-09-20 15:05:47 +02:00
Owen
f7293a3933
Adjust default to prefix 2025-09-20 15:05:47 +02:00
Marc Schäfer
47b1d52f67
feat(sites): adding official kubernetes helm install command for newt 2025-09-20 15:05:46 +02:00
Owen
cb80c6eca4
Add default to siteResources niceId 2025-09-20 15:05:46 +02:00
Owen
8d91fdb987
Adjust headers to work as name value 2025-09-20 15:05:46 +02:00
Owen Schwartz
2e037d2c0f
New translations en-us.json (Norwegian Bokmal) 2025-09-20 15:05:46 +02:00
Owen Schwartz
ea01e019c7
New translations en-us.json (Chinese Simplified) 2025-09-20 15:05:45 +02:00
Owen Schwartz
c74cc48b83
New translations en-us.json (Turkish) 2025-09-20 15:05:45 +02:00
Owen Schwartz
f33a0d4fc8
New translations en-us.json (Russian) 2025-09-20 15:05:45 +02:00
Owen Schwartz
911faf9843
New translations en-us.json (Portuguese) 2025-09-20 15:05:45 +02:00
Owen Schwartz
fa115b455d
New translations en-us.json (Polish) 2025-09-20 15:05:44 +02:00
Owen Schwartz
30b6d42047
New translations en-us.json (Dutch) 2025-09-20 15:05:44 +02:00
Owen Schwartz
2a04f58f0d
New translations en-us.json (Korean) 2025-09-20 15:05:44 +02:00
Owen Schwartz
ef0bfc8844
New translations en-us.json (Italian) 2025-09-20 15:05:44 +02:00
Owen Schwartz
aac4a93550
New translations en-us.json (German) 2025-09-20 15:05:43 +02:00
Owen Schwartz
451aa5ecfd
New translations en-us.json (Czech) 2025-09-20 15:05:43 +02:00
Owen Schwartz
4496610714
New translations en-us.json (Bulgarian) 2025-09-20 15:05:43 +02:00
Owen Schwartz
d46fa115a8
New translations en-us.json (Spanish) 2025-09-20 15:05:43 +02:00
Owen Schwartz
dec208cdea
New translations en-us.json (French) 2025-09-20 15:05:42 +02:00
Lokowitz
5984079995
modified: package-lock.json
modified:   package.json
2025-09-20 15:05:42 +02:00
Lokowitz
aa43d1936f
modified: package-lock.json
modified:   package.json
2025-09-20 15:05:42 +02:00
Lokowitz
d7d1af59b5
modified: package-lock.json
modified:   package.json
2025-09-20 15:05:41 +02:00
Lokowitz
adca1348b6
modified: src/app/[orgId]/settings/access/layout.tsx 2025-09-20 15:05:41 +02:00
dependabot[bot]
915fa3e8e1
Bump next in the npm_and_yarn group across 1 directory (#336)
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 15.4.6 to 15.5.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.4.6...v15.5.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.3
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 15:05:41 +02:00
Marvin
cc009a14d6
Update page.tsx 2025-09-20 15:05:41 +02:00
Marvin
48ab3f9a80
Update layout.tsx 2025-09-20 15:05:40 +02:00
Marvin
52e56744c0
layout.tsx aktualisieren 2025-09-20 15:05:40 +02:00
dependabot[bot]
3b74f84665
Bump the prod-minor-updates group across 1 directory with 7 updates (#335) 2025-09-20 15:05:40 +02:00
dependabot[bot]
901c0483c1
Bump uuid from 11.1.0 to 13.0.0 (#334) 2025-09-20 15:05:09 +02:00
dependabot[bot]
1bc5b6b472
Bump the dev-patch-updates group with 6 updates (#329)
Bumps the dev-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.1.12` | `4.1.13` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.3.0` | `24.3.3` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.1.12` | `19.1.13` |
| [@types/semver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver) | `7.7.0` | `7.7.1` |
| [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `4.2.8` | `4.2.11` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.12` | `4.1.13` |


Updates `@tailwindcss/postcss` from 4.1.12 to 4.1.13
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.13/packages/@tailwindcss-postcss)

Updates `@types/node` from 24.3.0 to 24.3.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/react` from 19.1.12 to 19.1.13
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/semver` from 7.7.0 to 7.7.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

Updates `react-email` from 4.2.8 to 4.2.11
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/react-email@4.2.11/packages/react-email)

Updates `tailwindcss` from 4.1.12 to 4.1.13
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.13/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/node"
  dependency-version: 24.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/semver"
  dependency-version: 7.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-email
  dependency-version: 4.2.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.1.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 15:05:08 +02:00
dependabot[bot]
009f7b23d9
Bump the prod-patch-updates group with 7 updates (#332)
Bumps the prod-patch-updates group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@react-email/components](https://github.com/resend/react-email/tree/HEAD/packages/components) | `0.5.0` | `0.5.3` |
| [@react-email/render](https://github.com/resend/react-email/tree/HEAD/packages/render) | `1.2.0` | `1.2.3` |
| [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.44.4` | `0.44.5` |
| [next-intl](https://github.com/amannn/next-intl) | `4.3.4` | `4.3.8` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `7.0.5` | `7.0.6` |
| [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) | `6.4.17` | `7.0.1` |
| [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css) | `1.3.7` | `1.3.8` |


Updates `@react-email/components` from 0.5.0 to 0.5.3
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/components/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/components@0.5.3/packages/components)

Updates `@react-email/render` from 1.2.0 to 1.2.3
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/render/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/render@1.2.3/packages/render)

Updates `drizzle-orm` from 0.44.4 to 0.44.5
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.44.4...0.44.5)

Updates `next-intl` from 4.3.4 to 4.3.8
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.3.4...v4.3.8)

Updates `nodemailer` from 7.0.5 to 7.0.6
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v7.0.5...v7.0.6)

Updates `@types/nodemailer` from 6.4.17 to 7.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `tw-animate-css` from 1.3.7 to 1.3.8
- [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases)
- [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.3.7...v1.3.8)

---
updated-dependencies:
- dependency-name: "@react-email/components"
  dependency-version: 0.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@react-email/render"
  dependency-version: 1.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: drizzle-orm
  dependency-version: 0.44.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: next-intl
  dependency-version: 4.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nodemailer
  dependency-version: 7.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@types/nodemailer"
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: prod-patch-updates
- dependency-name: tw-animate-css
  dependency-version: 1.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 15:05:08 +02:00
dependabot[bot]
f9ea02065e
Bump typescript-eslint in the dev-minor-updates group (#331)
Bumps the dev-minor-updates group with 1 update: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `typescript-eslint` from 8.40.0 to 8.43.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.43.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.43.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 15:05:08 +02:00
Milo Schwartz
fa36aeca1c
Update README.md 2025-09-20 15:04:07 +02:00
dependabot[bot]
a0b7f14b9e
Bump golang.org/x/term in /install in the prod-minor-updates group
Bumps the prod-minor-updates group in /install with 1 update: [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/term` from 0.34.0 to 0.35.0
- [Commits](https://github.com/golang/term/compare/v0.34.0...v0.35.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:04:07 +02:00
Owen
7f6ee7c88d
Translate 2025-09-20 15:04:06 +02:00
Owen
8f29c42ab4
Working on making blueprints work 2025-09-20 15:04:06 +02:00
Owen
7f4d410cbd
Update migrations 2025-09-20 15:04:06 +02:00
Owen
76e19508f0
Rules, client resources working 2025-09-20 15:04:06 +02:00
Owen
6d210b128d
Site resources for the blueprint 2025-09-20 15:04:05 +02:00
Owen
ef5f713bfb
Adjust styling to make it more clear 2025-09-20 15:04:05 +02:00
Owen
fc24331988
Eslint fix 2025-09-20 15:04:05 +02:00
Owen
75de3118ab
Update migrations 2025-09-20 15:04:05 +02:00
Owen Schwartz
597c15b83d
New translations en-us.json (Norwegian Bokmal) 2025-09-20 15:04:04 +02:00
Owen Schwartz
3027a0dc13
New translations en-us.json (Chinese Simplified) 2025-09-20 15:04:04 +02:00
Owen Schwartz
d27ed4d523
New translations en-us.json (Turkish) 2025-09-20 15:04:04 +02:00
Owen Schwartz
0522b93369
New translations en-us.json (Russian) 2025-09-20 15:04:04 +02:00
Owen Schwartz
47eb1262e0
New translations en-us.json (Portuguese) 2025-09-20 15:04:03 +02:00
Owen Schwartz
b503d73e46
New translations en-us.json (Polish) 2025-09-20 15:04:03 +02:00
Owen Schwartz
2bacfa8002
New translations en-us.json (Dutch) 2025-09-20 15:04:03 +02:00
Owen Schwartz
0d684c1d01
New translations en-us.json (Korean) 2025-09-20 15:04:03 +02:00
Owen Schwartz
e93a2a6f4d
New translations en-us.json (Italian) 2025-09-20 15:04:02 +02:00
Owen Schwartz
5223de6b92
New translations en-us.json (German) 2025-09-20 15:04:02 +02:00
Owen Schwartz
824e8a2818
New translations en-us.json (Czech) 2025-09-20 15:04:02 +02:00
Owen Schwartz
96e4a28a2a
New translations en-us.json (Bulgarian) 2025-09-20 15:04:02 +02:00
Owen Schwartz
c2ac48400b
New translations en-us.json (Spanish) 2025-09-20 15:04:01 +02:00
Owen Schwartz
f63668dc1a
New translations en-us.json (French) 2025-09-20 15:04:01 +02:00
Owen
52c659feb9
Eslint fix 2025-09-20 15:04:01 +02:00
Owen
4220f518d0
Just style it a bit 2025-09-20 15:04:01 +02:00
hetlelid
bb57f7537e
Update page.tsx
Added default location for the config files, for reference
2025-09-20 15:04:00 +02:00
Owen
1ea1e2806e
Add migrations for 1.10.0 2025-09-20 15:04:00 +02:00
miloschwartz
599228274f
add hide free domain option to domain picker 2025-09-20 15:04:00 +02:00
Owen
471a15f3a0
Add prefix to ui and resource 2025-09-20 15:01:43 +02:00
Owen
bd425f5cfe
Handle different routers based on target path 2025-09-20 15:01:43 +02:00
Owen
50e94d2d1f
Headers input working on resource 2025-09-20 15:01:43 +02:00
Owen
997cbca98e
Get the headers into the traefik config 2025-09-20 15:01:42 +02:00
Owen
3a1eddea43
Work accross sites? 2025-09-20 15:01:42 +02:00
Owen
764778ecc2
Testing cross site issue 2025-09-20 15:01:42 +02:00
Owen
22f3dc9dac
Apply blueprint over api call 2025-09-20 15:01:42 +02:00
Owen
65b4b53064
Add basic blueprints 2025-09-20 15:01:41 +02:00
miloschwartz
f404b3bf35
fixed email undefined error on request email code 2025-09-20 15:01:41 +02:00
dependabot[bot]
16c16b81bc
Bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:01:41 +02:00
dependabot[bot]
d868545d0d
Bump actions/setup-go from 5 to 6
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:01:41 +02:00
dependabot[bot]
383f70f92f
Bump actions/stale from 9 to 10
Bumps [actions/stale](https://github.com/actions/stale) from 9 to 10.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v9...v10)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '10'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-20 15:01:40 +02:00
Milo Schwartz
22b9f926dc
Update README.md 2025-09-20 15:01:40 +02:00
AstralDestiny
f46e190f4a
Update Traefik to not declare an unnecessary path and make the config cleaner. 2025-09-20 15:01:18 +02:00
miloschwartz
cf11ac8853
add common domain validation func 2025-09-20 15:01:18 +02:00
miloschwartz
d040ca4208
pull api base url from config for axios 2025-09-20 14:59:09 +02:00
Owen Schwartz
34d9641aab
New translations en-us.json (Norwegian Bokmal) 2025-09-20 14:59:09 +02:00
Owen Schwartz
4a70868d82
New translations en-us.json (Chinese Simplified) 2025-09-20 14:59:09 +02:00
Owen Schwartz
fb0fee244f
New translations en-us.json (Turkish) 2025-09-20 14:59:08 +02:00
Owen Schwartz
60a4e31ff5
New translations en-us.json (Russian) 2025-09-20 14:59:08 +02:00
Owen Schwartz
01fef8ab09
New translations en-us.json (Portuguese) 2025-09-20 14:59:08 +02:00
Owen Schwartz
f7afdca5c0
New translations en-us.json (Polish) 2025-09-20 14:59:08 +02:00
Owen Schwartz
567e74046d
New translations en-us.json (Dutch) 2025-09-20 14:59:07 +02:00
Owen Schwartz
452338e67b
New translations en-us.json (Korean) 2025-09-20 14:59:07 +02:00
Owen Schwartz
c9dfff290b
New translations en-us.json (Italian) 2025-09-20 14:59:07 +02:00
Owen Schwartz
dac9608bef
New translations en-us.json (German) 2025-09-20 14:59:07 +02:00
Owen Schwartz
38c8f576e0
New translations en-us.json (Czech) 2025-09-20 14:59:06 +02:00
Owen Schwartz
553cc30836
New translations en-us.json (Bulgarian) 2025-09-20 14:59:06 +02:00
Owen Schwartz
3538bb793e
New translations en-us.json (Spanish) 2025-09-20 14:59:06 +02:00
Owen Schwartz
58ebd1275b
New translations en-us.json (French) 2025-09-20 14:59:06 +02:00
Owen Schwartz
791bcb2df1
New translations en-us.json (Norwegian Bokmal) 2025-09-20 14:59:05 +02:00
Owen Schwartz
227c898583
New translations en-us.json (Chinese Simplified) 2025-09-20 14:59:05 +02:00
Owen Schwartz
f889e54052
New translations en-us.json (Portuguese) 2025-09-20 14:59:05 +02:00
Owen Schwartz
2814d2410b
New translations en-us.json (Dutch) 2025-09-20 14:59:05 +02:00
Owen Schwartz
1b9ed8aab4
New translations en-us.json (Korean) 2025-09-20 14:59:04 +02:00
Pallavi
1cdecf8480
inconsistent destinationIp validation between create and update 2025-09-20 14:59:04 +02:00
Owen
d1890a2756
Resource identified with niceId now 2025-09-20 14:59:04 +02:00
Owen
a6df41cb43
Add resource niceId 2025-09-20 14:59:04 +02:00
Owen
068d8484fb
Fix #1423 2025-09-20 14:59:03 +02:00
Pallavi
cbee67e475
Include region field in ExitNode query to match schema 2025-09-20 14:59:03 +02:00
miloschwartz
60d392742d
update install link 2025-09-20 14:59:03 +02:00
miloschwartz
bc151df638
auto detect public ip 2025-09-20 14:59:03 +02:00
miloschwartz
685be473bc
remove extra components 2025-09-20 14:59:02 +02:00
miloschwartz
26cc0f8184
fix listIdp query error 2025-09-20 14:58:20 +02:00
Owen
5efd1d5405
Add region 2025-09-20 14:58:20 +02:00
miloschwartz
5fd411a54e
add idp auto provision override on user 2025-09-20 14:58:20 +02:00
Owen
35facd1d4c
Add node env for react email issue back 2025-09-20 14:53:21 +02:00
Owen
956db6ba2e
Update start command one more time 2025-09-20 14:53:21 +02:00
Owen
49d10eed33
Remove source map support 2025-09-20 14:53:21 +02:00
Owen
d55e4ef498
Update build process 2025-09-20 14:53:20 +02:00
miloschwartz
ce320ca2cf
increase telemetry report interval 2025-09-20 14:53:20 +02:00
miloschwartz
dba4918edf
add optional icon to strategy select 2025-09-20 14:53:14 +02:00
Owen
adc57375f2
Pass in db to pickPort 2025-09-20 14:53:13 +02:00
miloschwartz
be2c91415a
add oidc variant 2025-09-20 14:53:13 +02:00
miloschwartz
c1ba993ac8
scope user id check to idp in create idp user 2025-09-20 14:53:13 +02:00
Owen
16cbe9bb69
Add transaction type 2025-09-20 14:53:13 +02:00
Owen
52b465b289
Fix typo in response 2025-09-20 14:53:12 +02:00
miloschwartz
eba7f55a62
fix delete idp user 2025-09-20 14:53:12 +02:00
miloschwartz
e8585f2a66
remove special char domain placeholders 2025-09-20 14:53:12 +02:00
miloschwartz
459853ca99
move all components to components dir 2025-09-20 14:53:12 +02:00
Owen
a21de9f1cb
Add niceId to resource 2025-09-20 14:52:39 +02:00
Owen
f9f47f3869
Convert to exitNodeComm function 2025-09-20 14:52:38 +02:00
191 changed files with 8630 additions and 1655 deletions

View file

@ -28,7 +28,7 @@ jobs:
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Install Go - name: Install Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: 1.24 go-version: 1.24

View file

@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: '22' node-version: '22'

View file

@ -14,7 +14,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v10
with: with:
days-before-stale: 14 days-before-stale: 14
days-before-close: 14 days-before-close: 14

View file

@ -13,7 +13,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-node@v4 - uses: actions/setup-node@v5
with: with:
node-version: '22' node-version: '22'

View file

@ -20,15 +20,24 @@ _Pangolin tunnels your services to the internet so you can access anything from
Website Website
</a> </a>
<span> | </span> <span> | </span>
<a href="https://docs.digpangolin.com/self-host/quick-install"> <a href="https://docs.digpangolin.com/self-host/quick-install-managed">
Install Guide Quick Install Guide
</a> </a>
<span> | </span> <span> | </span>
<a href="mailto:numbat@fossorial.io"> <a href="mailto:contact@fossorial.io">
Contact Us Contact Us
</a> </a>
<span> | </span>
<a href="https://digpangolin.com/slack">
Slack
</a>
<span> | </span>
<a href="https://discord.gg/HCJR8Xhme4">
Discord
</a>
</h5> </h5>
[![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://digpangolin.com/slack)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) [![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) ![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square)
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4) [![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4)

72
blueprint.py Normal file
View file

@ -0,0 +1,72 @@
import requests
import yaml
import json
import base64
# The file path for the YAML file to be read
# You can change this to the path of your YAML file
YAML_FILE_PATH = 'blueprint.yaml'
# The API endpoint and headers from the curl request
API_URL = 'http://localhost:3004/v1/org/test/blueprint'
HEADERS = {
'accept': '*/*',
'Authorization': 'Bearer v7ix7xha1bmq2on.tzsden374mtmkeczm3tx44uzxsljnrst7nmg7ccr',
'Content-Type': 'application/json'
}
def convert_and_send(file_path, url, headers):
"""
Reads a YAML file, converts its content to a JSON payload,
and sends it via a PUT request to a specified URL.
"""
try:
# Read the YAML file content
with open(file_path, 'r') as file:
yaml_content = file.read()
# Parse the YAML string to a Python dictionary
# This will be used to ensure the YAML is valid before sending
parsed_yaml = yaml.safe_load(yaml_content)
# convert the parsed YAML to a JSON string
json_payload = json.dumps(parsed_yaml)
print("Converted JSON payload:")
print(json_payload)
# Encode the JSON string to Base64
encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')
# Create the final payload with the base64 encoded data
final_payload = {
"blueprint": encoded_json
}
print("Sending the following Base64 encoded JSON payload:")
print(final_payload)
print("-" * 20)
# Make the PUT request with the base64 encoded payload
response = requests.put(url, headers=headers, json=final_payload)
# Print the API response for debugging
print(f"API Response Status Code: {response.status_code}")
print("API Response Content:")
print(response.text)
# Raise an exception for bad status codes (4xx or 5xx)
response.raise_for_status()
except FileNotFoundError:
print(f"Error: The file '{file_path}' was not found.")
except yaml.YAMLError as e:
print(f"Error parsing YAML file: {e}")
except requests.exceptions.RequestException as e:
print(f"An error occurred during the API request: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
# Run the function
if __name__ == "__main__":
convert_and_send(YAML_FILE_PATH, API_URL, HEADERS)

69
blueprint.yaml Normal file
View file

@ -0,0 +1,69 @@
client-resources:
client-resource-nice-id-uno:
name: this is my resource
protocol: tcp
proxy-port: 3001
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
client-resource-nice-id-duce:
name: this is my resource
protocol: udp
proxy-port: 3000
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
proxy-resources:
resource-nice-id-uno:
name: this is my resource
protocol: http
full-domain: duce.test.example.com
host-header: example.com
tls-server-name: example.com
# auth:
# pincode: 123456
# password: sadfasdfadsf
# sso-enabled: true
# sso-roles:
# - Member
# sso-users:
# - owen@fossorial.io
# whitelist-users:
# - owen@fossorial.io
headers:
- name: X-Example-Header
value: example-value
- name: X-Another-Header
value: another-value
rules:
- action: allow
match: ip
value: 1.1.1.1
- action: deny
match: cidr
value: 2.2.2.2/32
- action: pass
match: path
value: /admin
targets:
- site: lively-yosemite-toad
path: /path
pathMatchType: prefix
hostname: localhost
method: http
port: 8000
- site: slim-alpine-chipmunk
hostname: localhost
path: /yoman
pathMatchType: exact
method: http
port: 8001
resource-nice-id-duce:
name: this is other resource
protocol: tcp
proxy-port: 3000
targets:
- site: lively-yosemite-toad
hostname: localhost
port: 3000

View file

@ -16,8 +16,9 @@ http:
# Next.js router (handles everything except API and WebSocket paths) # Next.js router (handles everything except API and WebSocket paths)
next-router: next-router:
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`)"
service: next-service service: next-service
priority: 10
entryPoints: entryPoints:
- websecure - websecure
tls: tls:
@ -27,15 +28,7 @@ http:
api-router: api-router:
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
service: api-service service: api-service
entryPoints: priority: 100
- websecure
tls:
certResolver: letsencrypt
# WebSocket router
ws-router:
rule: "Host(`{{.DashboardDomain}}`)"
service: api-service
entryPoints: entryPoints:
- websecure - websecure
tls: tls:

View file

@ -13,7 +13,6 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- ENVIRONMENT=dev - ENVIRONMENT=dev
- DB_TYPE=pg
volumes: volumes:
# Mount source code for hot reload # Mount source code for hot reload
- ./src:/app/src - ./src:/app/src

View file

@ -52,7 +52,7 @@ esbuild
bundle: true, bundle: true,
outfile: argv.out, outfile: argv.out,
format: "esm", format: "esm",
minify: true, minify: false,
banner: { banner: {
js: banner, js: banner,
}, },
@ -63,7 +63,7 @@ esbuild
packagePath: getPackagePaths(), packagePath: getPackagePaths(),
}), }),
], ],
sourcemap: "external", sourcemap: "inline",
target: "node22", target: "node22",
}) })
.then(() => { .then(() => {

View file

@ -1,10 +1,10 @@
module installer module installer
go 1.24 go 1.24.0
require ( require (
golang.org/x/term v0.34.0 golang.org/x/term v0.35.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require golang.org/x/sys v0.35.0 // indirect require golang.org/x/sys v0.36.0 // indirect

View file

@ -1,7 +1,7 @@
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -8,6 +8,8 @@ import (
"io" "io"
"io/fs" "io/fs"
"math/rand" "math/rand"
"net"
"net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -15,14 +17,13 @@ import (
"strings" "strings"
"text/template" "text/template"
"time" "time"
"net"
) )
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD // DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
func loadVersions(config *Config) { func loadVersions(config *Config) {
config.PangolinVersion = "replaceme" config.PangolinVersion = "1.9.4"
config.GerbilVersion = "replaceme" config.GerbilVersion = "1.2.1"
config.BadgerVersion = "replaceme" config.BadgerVersion = "1.2.0"
} }
//go:embed config/* //go:embed config/*
@ -74,7 +75,7 @@ func main() {
if err := checkPortsAvailable(p); err != nil { if err := checkPortsAvailable(p); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
fmt.Printf("Please close any services on ports 80/443 in order to run the installation smoothly") fmt.Printf("Please close any services on ports 80/443 in order to run the installation smoothly. If you already have the Pangolin stack running, shut them down before proceeding.\n")
os.Exit(1) os.Exit(1)
} }
} }
@ -126,7 +127,7 @@ func main() {
if readBool(reader, "Would you like to install and start the containers?", true) { if readBool(reader, "Would you like to install and start the containers?", true) {
config.InstallationContainerType = podmanOrDocker(reader) config.InstallationContainerType = podmanOrDocker(reader)
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker { if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
if readBool(reader, "Docker is not installed. Would you like to install it?", true) { if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker() installDocker()
@ -204,8 +205,17 @@ func main() {
} }
} }
config.InstallationContainerType = podmanOrDocker(reader)
config.DoCrowdsecInstall = true config.DoCrowdsecInstall = true
installCrowdsec(config) err := installCrowdsec(config)
if (err != nil) {
fmt.Printf("Error installing CrowdSec: %v\n", err)
return
}
fmt.Println("CrowdSec installed successfully!")
return
} }
} }
} }
@ -322,13 +332,18 @@ func collectUserInput(reader *bufio.Reader) Config {
if config.HybridMode { if config.HybridMode {
alreadyHaveCreds := readBool(reader, "Do you already have credentials from the dashboard? If not, we will create them later", false) alreadyHaveCreds := readBool(reader, "Do you already have credentials from the dashboard? If not, we will create them later", false)
if alreadyHaveCreds { if alreadyHaveCreds {
config.HybridId = readString(reader, "Enter your ID", "") config.HybridId = readString(reader, "Enter your ID", "")
config.HybridSecret = readString(reader, "Enter your secret", "") config.HybridSecret = readString(reader, "Enter your secret", "")
} }
config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", "") // Try to get public IP as default
publicIP := getPublicIP()
if publicIP != "" {
fmt.Printf("Detected public IP: %s\n", publicIP)
}
config.DashboardDomain = readString(reader, "The public addressable IP address for this node or a domain pointing to it", publicIP)
config.InstallGerbil = true config.InstallGerbil = true
} else { } else {
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
@ -345,7 +360,7 @@ func collectUserInput(reader *bufio.Reader) Config {
// Email configuration // Email configuration
fmt.Println("\n=== Email Configuration ===") fmt.Println("\n=== Email Configuration ===")
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false) config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
if config.EnableEmail { if config.EnableEmail {
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "") config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587) config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
@ -353,7 +368,7 @@ func collectUserInput(reader *bufio.Reader) Config {
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword? config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
config.EmailNoReply = readString(reader, "Enter no-reply email address", "") config.EmailNoReply = readString(reader, "Enter no-reply email address", "")
} }
// Validate required fields // Validate required fields
if config.BaseDomain == "" { if config.BaseDomain == "" {
fmt.Println("Error: Domain name is required") fmt.Println("Error: Domain name is required")
@ -584,6 +599,32 @@ func generateRandomSecretKey() string {
return string(b) return string(b)
} }
func getPublicIP() string {
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://ifconfig.io/ip")
if err != nil {
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ""
}
ip := strings.TrimSpace(string(body))
// Validate that it's a valid IP address
if net.ParseIP(ip) != nil {
return ip
}
return ""
}
// Run external commands with stdio/stderr attached. // Run external commands with stdio/stderr attached.
func run(name string, args ...string) error { func run(name string, args ...string) error {
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
"userSaved": "User saved", "userSaved": "User saved",
"userSavedDescription": "The user has been updated.", "userSavedDescription": "The user has been updated.",
"autoProvisioned": "Auto Provisioned",
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls", "accessControlsSubmit": "Save Access Controls",
"roles": "Roles", "roles": "Roles",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Invalid IP address format", "ipAddressErrorInvalidFormat": "Invalid IP address format",
"ipAddressErrorInvalidOctet": "Invalid IP address octet", "ipAddressErrorInvalidOctet": "Invalid IP address octet",
"path": "Path", "path": "Path",
"matchPath": "Match Path",
"ipAddressRange": "IP Range", "ipAddressRange": "IP Range",
"rulesErrorFetch": "Failed to fetch rules", "rulesErrorFetch": "Failed to fetch rules",
"rulesErrorFetchDescription": "An error occurred while fetching rules", "rulesErrorFetchDescription": "An error occurred while fetching rules",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Connected", "idpConnectingToFinished": "Connected",
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found", "idpErrorNotFound": "IdP not found",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Invalid Invite", "inviteInvalid": "Invalid Invite",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user", "inviteErrorWrongUser": "Invite is not for this user",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequired": "Professional Edition Required",
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
"actionGetOrg": "Get Organization", "actionGetOrg": "Get Organization",
"updateOrgUser": "Update Org User",
"createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization", "actionUpdateOrg": "Update Organization",
"actionUpdateUser": "Update User", "actionUpdateUser": "Update User",
"actionGetUser": "Get User", "actionGetUser": "Get User",
@ -991,6 +998,7 @@
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"actionApplyBlueprint": "Apply Blueprint",
"setupToken": "Setup Token", "setupToken": "Setup Token",
"setupTokenDescription": "Enter the setup token from the server console.", "setupTokenDescription": "Enter the setup token from the server console.",
"setupTokenRequired": "Setup token is required", "setupTokenRequired": "Setup token is required",
@ -1133,8 +1141,8 @@
"sidebarLicense": "License", "sidebarLicense": "License",
"sidebarClients": "Clients (Beta)", "sidebarClients": "Clients (Beta)",
"sidebarDomains": "Domains", "sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket", "enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
"enableDockerSocketLink": "Learn More", "enableDockerSocketLink": "Learn More",
"viewDockerContainers": "View Docker Containers", "viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}", "containersIn": "Containers in {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Update Available", "newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Domain", "domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescription": "Enter the full domain of the resource to see available options.",
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
"domainPickerTabAll": "All", "domainPickerTabAll": "All",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogProtocol": "Protocol",
"editInternalResourceDialogSitePort": "Site Port", "editInternalResourceDialogSitePort": "Site Port",
"editInternalResourceDialogTargetConfiguration": "Target Configuration", "editInternalResourceDialogTargetConfiguration": "Target Configuration",
"editInternalResourceDialogDestinationIP": "Destination IP",
"editInternalResourceDialogDestinationPort": "Destination Port",
"editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogCancel": "Cancel",
"editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSaveResource": "Save Resource",
"editInternalResourceDialogSuccess": "Success", "editInternalResourceDialogSuccess": "Success",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Site Port", "createInternalResourceDialogSitePort": "Site Port",
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
"createInternalResourceDialogTargetConfiguration": "Target Configuration", "createInternalResourceDialogTargetConfiguration": "Target Configuration",
"createInternalResourceDialogDestinationIP": "Destination IP", "createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
"createInternalResourceDialogDestinationPort": "Destination Port",
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
"createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCancel": "Cancel",
"createInternalResourceDialogCreateResource": "Create Resource", "createInternalResourceDialogCreateResource": "Create Resource",
@ -1496,5 +1500,24 @@
"convertButton": "Convert This Node to Managed Self-Hosted" "convertButton": "Convert This Node to Managed Self-Hosted"
}, },
"internationaldomaindetected": "International Domain Detected", "internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:" "willbestoredas": "Will be stored as:",
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Custom Headers",
"headersValidationError": "Headers must be in the format: Header-Name: value.",
"domainPickerProvidedDomain": "Provided Domain",
"domainPickerFreeProvidedDomain": "Free Provided Domain",
"domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified",
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
"domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Failed to load organization domains",
"domainPickerErrorCheckAvailability": "Failed to check domain availability",
"domainPickerInvalidSubdomain": "Invalid subdomain",
"domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
"domainPickerSubdomainSanitized": "Subdomain sanitized",
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Edit file: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
"userSaved": "User saved", "userSaved": "User saved",
"userSavedDescription": "The user has been updated.", "userSavedDescription": "The user has been updated.",
"autoProvisioned": "Auto Provisioned",
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls", "accessControlsSubmit": "Save Access Controls",
"roles": "Roles", "roles": "Roles",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Invalid IP address format", "ipAddressErrorInvalidFormat": "Invalid IP address format",
"ipAddressErrorInvalidOctet": "Invalid IP address octet", "ipAddressErrorInvalidOctet": "Invalid IP address octet",
"path": "Path", "path": "Path",
"matchPath": "Match Path",
"ipAddressRange": "IP Range", "ipAddressRange": "IP Range",
"rulesErrorFetch": "Failed to fetch rules", "rulesErrorFetch": "Failed to fetch rules",
"rulesErrorFetchDescription": "An error occurred while fetching rules", "rulesErrorFetchDescription": "An error occurred while fetching rules",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Connected", "idpConnectingToFinished": "Connected",
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found", "idpErrorNotFound": "IdP not found",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Invalid Invite", "inviteInvalid": "Invalid Invite",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user", "inviteErrorWrongUser": "Invite is not for this user",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequired": "Professional Edition Required",
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
"actionGetOrg": "Get Organization", "actionGetOrg": "Get Organization",
"updateOrgUser": "Update Org User",
"createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization", "actionUpdateOrg": "Update Organization",
"actionUpdateUser": "Update User", "actionUpdateUser": "Update User",
"actionGetUser": "Get User", "actionGetUser": "Get User",
@ -991,6 +998,7 @@
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"actionApplyBlueprint": "Apply Blueprint",
"setupToken": "Setup Token", "setupToken": "Setup Token",
"setupTokenDescription": "Enter the setup token from the server console.", "setupTokenDescription": "Enter the setup token from the server console.",
"setupTokenRequired": "Setup token is required", "setupTokenRequired": "Setup token is required",
@ -1133,8 +1141,8 @@
"sidebarLicense": "License", "sidebarLicense": "License",
"sidebarClients": "Clients (Beta)", "sidebarClients": "Clients (Beta)",
"sidebarDomains": "Domains", "sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket", "enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
"enableDockerSocketLink": "Learn More", "enableDockerSocketLink": "Learn More",
"viewDockerContainers": "View Docker Containers", "viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}", "containersIn": "Containers in {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Update Available", "newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Domain", "domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescription": "Enter the full domain of the resource to see available options.",
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
"domainPickerTabAll": "All", "domainPickerTabAll": "All",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogProtocol": "Protocol",
"editInternalResourceDialogSitePort": "Site Port", "editInternalResourceDialogSitePort": "Site Port",
"editInternalResourceDialogTargetConfiguration": "Target Configuration", "editInternalResourceDialogTargetConfiguration": "Target Configuration",
"editInternalResourceDialogDestinationIP": "Destination IP",
"editInternalResourceDialogDestinationPort": "Destination Port",
"editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogCancel": "Cancel",
"editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSaveResource": "Save Resource",
"editInternalResourceDialogSuccess": "Success", "editInternalResourceDialogSuccess": "Success",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Site Port", "createInternalResourceDialogSitePort": "Site Port",
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
"createInternalResourceDialogTargetConfiguration": "Target Configuration", "createInternalResourceDialogTargetConfiguration": "Target Configuration",
"createInternalResourceDialogDestinationIP": "Destination IP", "createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
"createInternalResourceDialogDestinationPort": "Destination Port",
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
"createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCancel": "Cancel",
"createInternalResourceDialogCreateResource": "Create Resource", "createInternalResourceDialogCreateResource": "Create Resource",
@ -1496,5 +1500,24 @@
"convertButton": "Convert This Node to Managed Self-Hosted" "convertButton": "Convert This Node to Managed Self-Hosted"
}, },
"internationaldomaindetected": "International Domain Detected", "internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:" "willbestoredas": "Will be stored as:",
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Custom Headers",
"headersValidationError": "Headers must be in the format: Header-Name: value.",
"domainPickerProvidedDomain": "Provided Domain",
"domainPickerFreeProvidedDomain": "Free Provided Domain",
"domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified",
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
"domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Failed to load organization domains",
"domainPickerErrorCheckAvailability": "Failed to check domain availability",
"domainPickerInvalidSubdomain": "Invalid subdomain",
"domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
"domainPickerSubdomainSanitized": "Subdomain sanitized",
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Edit file: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.", "accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.",
"userSaved": "Benutzer gespeichert", "userSaved": "Benutzer gespeichert",
"userSavedDescription": "Der Benutzer wurde aktualisiert.", "userSavedDescription": "Der Benutzer wurde aktualisiert.",
"autoProvisioned": "Automatisch vorgesehen",
"autoProvisionedDescription": "Erlaube diesem Benutzer die automatische Verwaltung durch Identitätsanbieter",
"accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann", "accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann",
"accessControlsSubmit": "Zugriffskontrollen speichern", "accessControlsSubmit": "Zugriffskontrollen speichern",
"roles": "Rollen", "roles": "Rollen",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Ungültiges IP-Adressformat", "ipAddressErrorInvalidFormat": "Ungültiges IP-Adressformat",
"ipAddressErrorInvalidOctet": "Ungültiges IP-Adress-Oktett", "ipAddressErrorInvalidOctet": "Ungültiges IP-Adress-Oktett",
"path": "Pfad", "path": "Pfad",
"matchPath": "Unterverzeichnis",
"ipAddressRange": "IP-Bereich", "ipAddressRange": "IP-Bereich",
"rulesErrorFetch": "Fehler beim Abrufen der Regeln", "rulesErrorFetch": "Fehler beim Abrufen der Regeln",
"rulesErrorFetchDescription": "Beim Abrufen der Regeln ist ein Fehler aufgetreten", "rulesErrorFetchDescription": "Beim Abrufen der Regeln ist ein Fehler aufgetreten",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Verbunden", "idpConnectingToFinished": "Verbunden",
"idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.", "idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.",
"idpErrorNotFound": "IdP nicht gefunden", "idpErrorNotFound": "IdP nicht gefunden",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Ungültige Einladung", "inviteInvalid": "Ungültige Einladung",
"inviteInvalidDescription": "Der Einladungslink ist ungültig.", "inviteInvalidDescription": "Der Einladungslink ist ungültig.",
"inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer", "inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Professional Edition erforderlich", "licenseTierProfessionalRequired": "Professional Edition erforderlich",
"licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.", "licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.",
"actionGetOrg": "Organisation abrufen", "actionGetOrg": "Organisation abrufen",
"updateOrgUser": "Org Benutzer aktualisieren",
"createOrgUser": "Org Benutzer erstellen",
"actionUpdateOrg": "Organisation aktualisieren", "actionUpdateOrg": "Organisation aktualisieren",
"actionUpdateUser": "Benutzer aktualisieren", "actionUpdateUser": "Benutzer aktualisieren",
"actionGetUser": "Benutzer abrufen", "actionGetUser": "Benutzer abrufen",
@ -991,6 +998,7 @@
"actionDeleteSite": "Standort löschen", "actionDeleteSite": "Standort löschen",
"actionGetSite": "Standort abrufen", "actionGetSite": "Standort abrufen",
"actionListSites": "Standorte auflisten", "actionListSites": "Standorte auflisten",
"actionApplyBlueprint": "Blaupause anwenden",
"setupToken": "Setup-Token", "setupToken": "Setup-Token",
"setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.",
"setupTokenRequired": "Setup-Token ist erforderlich", "setupTokenRequired": "Setup-Token ist erforderlich",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Lizenz", "sidebarLicense": "Lizenz",
"sidebarClients": "Clients (Beta)", "sidebarClients": "Clients (Beta)",
"sidebarDomains": "Domains", "sidebarDomains": "Domains",
"enableDockerSocket": "Docker Socket aktivieren", "enableDockerSocket": "Docker Blaupause aktivieren",
"enableDockerSocketDescription": "Docker Socket-Erkennung aktivieren, um Container-Informationen zu befüllen. Socket-Pfad muss Newt bereitgestellt werden.", "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blaupausenbeschriftungen. Der Socket-Pfad muss neu angegeben werden.",
"enableDockerSocketLink": "Mehr erfahren", "enableDockerSocketLink": "Mehr erfahren",
"viewDockerContainers": "Docker Container anzeigen", "viewDockerContainers": "Docker Container anzeigen",
"containersIn": "Container in {siteName}", "containersIn": "Container in {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailable": "Update verfügbar",
"newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
"domainPickerEnterDomain": "Domain", "domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, oder einfach myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.", "domainPickerDescription": "Geben Sie die vollständige Domäne der Ressource ein, um verfügbare Optionen zu sehen.",
"domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen", "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domäne, Subdomäne oder einfach einen Namen ein, um verfügbare Optionen zu sehen",
"domainPickerTabAll": "Alle", "domainPickerTabAll": "Alle",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protokoll", "editInternalResourceDialogProtocol": "Protokoll",
"editInternalResourceDialogSitePort": "Site-Port", "editInternalResourceDialogSitePort": "Site-Port",
"editInternalResourceDialogTargetConfiguration": "Zielkonfiguration", "editInternalResourceDialogTargetConfiguration": "Zielkonfiguration",
"editInternalResourceDialogDestinationIP": "Ziel-IP",
"editInternalResourceDialogDestinationPort": "Ziel-Port",
"editInternalResourceDialogCancel": "Abbrechen", "editInternalResourceDialogCancel": "Abbrechen",
"editInternalResourceDialogSaveResource": "Ressource speichern", "editInternalResourceDialogSaveResource": "Ressource speichern",
"editInternalResourceDialogSuccess": "Erfolg", "editInternalResourceDialogSuccess": "Erfolg",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Site-Port", "createInternalResourceDialogSitePort": "Site-Port",
"createInternalResourceDialogSitePortDescription": "Verwenden Sie diesen Port, um bei Verbindung mit einem Client auf die Ressource an der Site zuzugreifen.", "createInternalResourceDialogSitePortDescription": "Verwenden Sie diesen Port, um bei Verbindung mit einem Client auf die Ressource an der Site zuzugreifen.",
"createInternalResourceDialogTargetConfiguration": "Zielkonfiguration", "createInternalResourceDialogTargetConfiguration": "Zielkonfiguration",
"createInternalResourceDialogDestinationIP": "Ziel-IP", "createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.",
"createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse der Ressource im Netzwerkstandort der Site.",
"createInternalResourceDialogDestinationPort": "Ziel-Port",
"createInternalResourceDialogDestinationPortDescription": "Der Port auf der Ziel-IP, unter dem die Ressource zugänglich ist.", "createInternalResourceDialogDestinationPortDescription": "Der Port auf der Ziel-IP, unter dem die Ressource zugänglich ist.",
"createInternalResourceDialogCancel": "Abbrechen", "createInternalResourceDialogCancel": "Abbrechen",
"createInternalResourceDialogCreateResource": "Ressource erstellen", "createInternalResourceDialogCreateResource": "Ressource erstellen",
@ -1496,5 +1500,24 @@
"convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln" "convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln"
}, },
"internationaldomaindetected": "Internationale Domain erkannt", "internationaldomaindetected": "Internationale Domain erkannt",
"willbestoredas": "Wird gespeichert als:" "willbestoredas": "Wird gespeichert als:",
"idpGoogleDescription": "Google OAuth2/OIDC Provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Eigene Kopfzeilen",
"headersValidationError": "Header müssen im Format Header-Name: Wert sein.",
"domainPickerProvidedDomain": "Angegebene Domain",
"domainPickerFreeProvidedDomain": "Kostenlose Domain",
"domainPickerVerified": "Verifiziert",
"domainPickerUnverified": "Nicht verifiziert",
"domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.",
"domainPickerError": "Fehler",
"domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domänen",
"domainPickerErrorCheckAvailability": "Fehler beim Prüfen der Domain-Verfügbarkeit",
"domainPickerInvalidSubdomain": "Ungültige Subdomain",
"domainPickerInvalidSubdomainRemoved": "Die Eingabe \"{sub}\" wurde entfernt, weil sie nicht gültig ist.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" konnte nicht für {domain} gültig gemacht werden.",
"domainPickerSubdomainSanitized": "Subdomain bereinigt",
"domainPickerSubdomainCorrected": "\"{sub}\" wurde korrigiert zu \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Datei bearbeiten: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Datei bearbeiten: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
"userSaved": "User saved", "userSaved": "User saved",
"userSavedDescription": "The user has been updated.", "userSavedDescription": "The user has been updated.",
"autoProvisioned": "Auto Provisioned",
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls", "accessControlsSubmit": "Save Access Controls",
"roles": "Roles", "roles": "Roles",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Invalid IP address format", "ipAddressErrorInvalidFormat": "Invalid IP address format",
"ipAddressErrorInvalidOctet": "Invalid IP address octet", "ipAddressErrorInvalidOctet": "Invalid IP address octet",
"path": "Path", "path": "Path",
"matchPath": "Match Path",
"ipAddressRange": "IP Range", "ipAddressRange": "IP Range",
"rulesErrorFetch": "Failed to fetch rules", "rulesErrorFetch": "Failed to fetch rules",
"rulesErrorFetchDescription": "An error occurred while fetching rules", "rulesErrorFetchDescription": "An error occurred while fetching rules",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Connected", "idpConnectingToFinished": "Connected",
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
"idpErrorNotFound": "IdP not found", "idpErrorNotFound": "IdP not found",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Invalid Invite", "inviteInvalid": "Invalid Invite",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
"inviteErrorWrongUser": "Invite is not for this user", "inviteErrorWrongUser": "Invite is not for this user",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequired": "Professional Edition Required",
"licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.",
"actionGetOrg": "Get Organization", "actionGetOrg": "Get Organization",
"updateOrgUser": "Update Org User",
"createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization", "actionUpdateOrg": "Update Organization",
"actionUpdateUser": "Update User", "actionUpdateUser": "Update User",
"actionGetUser": "Get User", "actionGetUser": "Get User",
@ -991,6 +998,7 @@
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"actionApplyBlueprint": "Apply Blueprint",
"setupToken": "Setup Token", "setupToken": "Setup Token",
"setupTokenDescription": "Enter the setup token from the server console.", "setupTokenDescription": "Enter the setup token from the server console.",
"setupTokenRequired": "Setup token is required", "setupTokenRequired": "Setup token is required",
@ -1133,8 +1141,8 @@
"sidebarLicense": "License", "sidebarLicense": "License",
"sidebarClients": "Clients (Beta)", "sidebarClients": "Clients (Beta)",
"sidebarDomains": "Domains", "sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket", "enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
"enableDockerSocketLink": "Learn More", "enableDockerSocketLink": "Learn More",
"viewDockerContainers": "View Docker Containers", "viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}", "containersIn": "Containers in {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Update Available", "newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Domain", "domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescription": "Enter the full domain of the resource to see available options.",
"domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options",
"domainPickerTabAll": "All", "domainPickerTabAll": "All",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogProtocol": "Protocol",
"editInternalResourceDialogSitePort": "Site Port", "editInternalResourceDialogSitePort": "Site Port",
"editInternalResourceDialogTargetConfiguration": "Target Configuration", "editInternalResourceDialogTargetConfiguration": "Target Configuration",
"editInternalResourceDialogDestinationIP": "Destination IP",
"editInternalResourceDialogDestinationPort": "Destination Port",
"editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogCancel": "Cancel",
"editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSaveResource": "Save Resource",
"editInternalResourceDialogSuccess": "Success", "editInternalResourceDialogSuccess": "Success",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Site Port", "createInternalResourceDialogSitePort": "Site Port",
"createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.",
"createInternalResourceDialogTargetConfiguration": "Target Configuration", "createInternalResourceDialogTargetConfiguration": "Target Configuration",
"createInternalResourceDialogDestinationIP": "Destination IP", "createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
"createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.",
"createInternalResourceDialogDestinationPort": "Destination Port",
"createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.",
"createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCancel": "Cancel",
"createInternalResourceDialogCreateResource": "Create Resource", "createInternalResourceDialogCreateResource": "Create Resource",
@ -1496,5 +1500,24 @@
"convertButton": "Convert This Node to Managed Self-Hosted" "convertButton": "Convert This Node to Managed Self-Hosted"
}, },
"internationaldomaindetected": "International Domain Detected", "internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:" "willbestoredas": "Will be stored as:",
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Custom Headers",
"headersValidationError": "Headers must be in the format: Header-Name: value.",
"domainPickerProvidedDomain": "Provided Domain",
"domainPickerFreeProvidedDomain": "Free Provided Domain",
"domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified",
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
"domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Failed to load organization domains",
"domainPickerErrorCheckAvailability": "Failed to check domain availability",
"domainPickerInvalidSubdomain": "Invalid subdomain",
"domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.",
"domainPickerSubdomainSanitized": "Subdomain sanitized",
"domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Edit file: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Ocurrió un error mientras se añadía el usuario al rol.", "accessRoleErrorAddDescription": "Ocurrió un error mientras se añadía el usuario al rol.",
"userSaved": "Usuario guardado", "userSaved": "Usuario guardado",
"userSavedDescription": "El usuario ha sido actualizado.", "userSavedDescription": "El usuario ha sido actualizado.",
"autoProvisioned": "Auto asegurado",
"autoProvisionedDescription": "Permitir a este usuario ser administrado automáticamente por el proveedor de identidad",
"accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización", "accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización",
"accessControlsSubmit": "Guardar controles de acceso", "accessControlsSubmit": "Guardar controles de acceso",
"roles": "Roles", "roles": "Roles",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Formato de dirección IP inválido", "ipAddressErrorInvalidFormat": "Formato de dirección IP inválido",
"ipAddressErrorInvalidOctet": "Octet de dirección IP no válido", "ipAddressErrorInvalidOctet": "Octet de dirección IP no válido",
"path": "Ruta", "path": "Ruta",
"matchPath": "Coincidir ruta",
"ipAddressRange": "Rango IP", "ipAddressRange": "Rango IP",
"rulesErrorFetch": "Error al obtener las reglas", "rulesErrorFetch": "Error al obtener las reglas",
"rulesErrorFetchDescription": "Se ha producido un error al recuperar las reglas", "rulesErrorFetchDescription": "Se ha producido un error al recuperar las reglas",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Conectado", "idpConnectingToFinished": "Conectado",
"idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.", "idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.",
"idpErrorNotFound": "IdP no encontrado", "idpErrorNotFound": "IdP no encontrado",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Invitación inválida", "inviteInvalid": "Invitación inválida",
"inviteInvalidDescription": "El enlace de invitación no es válido.", "inviteInvalidDescription": "El enlace de invitación no es válido.",
"inviteErrorWrongUser": "La invitación no es para este usuario", "inviteErrorWrongUser": "La invitación no es para este usuario",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Edición Profesional requerida", "licenseTierProfessionalRequired": "Edición Profesional requerida",
"licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.", "licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.",
"actionGetOrg": "Obtener organización", "actionGetOrg": "Obtener organización",
"updateOrgUser": "Actualizar usuario Org",
"createOrgUser": "Crear usuario Org",
"actionUpdateOrg": "Actualizar organización", "actionUpdateOrg": "Actualizar organización",
"actionUpdateUser": "Actualizar usuario", "actionUpdateUser": "Actualizar usuario",
"actionGetUser": "Obtener usuario", "actionGetUser": "Obtener usuario",
@ -991,6 +998,7 @@
"actionDeleteSite": "Eliminar sitio", "actionDeleteSite": "Eliminar sitio",
"actionGetSite": "Obtener sitio", "actionGetSite": "Obtener sitio",
"actionListSites": "Listar sitios", "actionListSites": "Listar sitios",
"actionApplyBlueprint": "Aplicar plano",
"setupToken": "Configuración de token", "setupToken": "Configuración de token",
"setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.",
"setupTokenRequired": "Se requiere el token de configuración", "setupTokenRequired": "Se requiere el token de configuración",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Licencia", "sidebarLicense": "Licencia",
"sidebarClients": "Clientes (Beta)", "sidebarClients": "Clientes (Beta)",
"sidebarDomains": "Dominios", "sidebarDomains": "Dominios",
"enableDockerSocket": "Habilitar conector Docker", "enableDockerSocket": "Habilitar Plano Docker",
"enableDockerSocketDescription": "Habilitar el descubrimiento de Docker Socket para completar la información del contenedor. La ruta del socket debe proporcionarse a Newt.", "enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.",
"enableDockerSocketLink": "Saber más", "enableDockerSocketLink": "Saber más",
"viewDockerContainers": "Ver contenedores Docker", "viewDockerContainers": "Ver contenedores Docker",
"containersIn": "Contenedores en {siteName}", "containersIn": "Contenedores en {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Nueva actualización disponible", "newtUpdateAvailable": "Nueva actualización disponible",
"newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", "newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.",
"domainPickerEnterDomain": "Dominio", "domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "myapp.example.com, api.v1.miDominio.com, o solo myapp", "domainPickerPlaceholder": "miapp.ejemplo.com",
"domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.",
"domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles", "domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles",
"domainPickerTabAll": "Todo", "domainPickerTabAll": "Todo",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocolo", "editInternalResourceDialogProtocol": "Protocolo",
"editInternalResourceDialogSitePort": "Puerto del sitio", "editInternalResourceDialogSitePort": "Puerto del sitio",
"editInternalResourceDialogTargetConfiguration": "Configuración de objetivos", "editInternalResourceDialogTargetConfiguration": "Configuración de objetivos",
"editInternalResourceDialogDestinationIP": "IP de destino",
"editInternalResourceDialogDestinationPort": "Puerto de destino",
"editInternalResourceDialogCancel": "Cancelar", "editInternalResourceDialogCancel": "Cancelar",
"editInternalResourceDialogSaveResource": "Guardar recurso", "editInternalResourceDialogSaveResource": "Guardar recurso",
"editInternalResourceDialogSuccess": "Éxito", "editInternalResourceDialogSuccess": "Éxito",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Puerto del sitio", "createInternalResourceDialogSitePort": "Puerto del sitio",
"createInternalResourceDialogSitePortDescription": "Use este puerto para acceder al recurso en el sitio cuando se conecta con un cliente.", "createInternalResourceDialogSitePortDescription": "Use este puerto para acceder al recurso en el sitio cuando se conecta con un cliente.",
"createInternalResourceDialogTargetConfiguration": "Configuración de objetivos", "createInternalResourceDialogTargetConfiguration": "Configuración de objetivos",
"createInternalResourceDialogDestinationIP": "IP de destino", "createInternalResourceDialogDestinationIPDescription": "La dirección IP o nombre de host del recurso en la red del sitio.",
"createInternalResourceDialogDestinationIPDescription": "La dirección IP del recurso en la red del sitio.",
"createInternalResourceDialogDestinationPort": "Puerto de destino",
"createInternalResourceDialogDestinationPortDescription": "El puerto en la IP de destino donde el recurso es accesible.", "createInternalResourceDialogDestinationPortDescription": "El puerto en la IP de destino donde el recurso es accesible.",
"createInternalResourceDialogCancel": "Cancelar", "createInternalResourceDialogCancel": "Cancelar",
"createInternalResourceDialogCreateResource": "Crear recurso", "createInternalResourceDialogCreateResource": "Crear recurso",
@ -1496,5 +1500,24 @@
"convertButton": "Convierte este nodo a autoalojado administrado" "convertButton": "Convierte este nodo a autoalojado administrado"
}, },
"internationaldomaindetected": "Dominio Internacional detectado", "internationaldomaindetected": "Dominio Internacional detectado",
"willbestoredas": "Se almacenará como:" "willbestoredas": "Se almacenará como:",
"idpGoogleDescription": "Proveedor OAuth2/OIDC de Google",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Cabeceras personalizadas",
"headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.",
"domainPickerProvidedDomain": "Dominio proporcionado",
"domainPickerFreeProvidedDomain": "Dominio proporcionado gratis",
"domainPickerVerified": "Verificado",
"domainPickerUnverified": "Sin verificar",
"domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.",
"domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización",
"domainPickerErrorCheckAvailability": "No se pudo comprobar la disponibilidad del dominio",
"domainPickerInvalidSubdomain": "Subdominio inválido",
"domainPickerInvalidSubdomainRemoved": "La entrada \"{sub}\" fue eliminada porque no es válida.",
"domainPickerInvalidSubdomainCannotMakeValid": "No se ha podido hacer válido \"{sub}\" para {domain}.",
"domainPickerSubdomainSanitized": "Subdominio saneado",
"domainPickerSubdomainCorrected": "\"{sub}\" fue corregido a \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Editar archivo: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Editar archivo: docker-compose.yml"
} }

View file

@ -10,7 +10,7 @@
"setupErrorIdentifier": "L'ID de l'organisation est déjà pris. Veuillez en choisir un autre.", "setupErrorIdentifier": "L'ID de l'organisation est déjà pris. Veuillez en choisir un autre.",
"componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.", "componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.",
"componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.", "componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.",
"welcome": "Bienvenue à Pangolin", "welcome": "Bienvenue sur Pangolin",
"welcomeTo": "Bienvenue chez", "welcomeTo": "Bienvenue chez",
"componentsCreateOrg": "Créer une organisation", "componentsCreateOrg": "Créer une organisation",
"componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.", "componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.",
@ -34,13 +34,13 @@
"confirmPassword": "Confirmer le mot de passe", "confirmPassword": "Confirmer le mot de passe",
"createAccount": "Créer un compte", "createAccount": "Créer un compte",
"viewSettings": "Afficher les paramètres", "viewSettings": "Afficher les paramètres",
"delete": "Supprimez", "delete": "Supprimer",
"name": "Nom", "name": "Nom",
"online": "En ligne", "online": "En ligne",
"offline": "Hors ligne", "offline": "Hors ligne",
"site": "Site", "site": "Site",
"dataIn": "Données dans", "dataIn": "Données reçues",
"dataOut": "Données épuisées", "dataOut": "Données envoyées",
"connectionType": "Type de connexion", "connectionType": "Type de connexion",
"tunnelType": "Type de tunnel", "tunnelType": "Type de tunnel",
"local": "Locale", "local": "Locale",
@ -175,7 +175,7 @@
"resourceHTTPSSettingsDescription": "Configurer comment votre ressource sera accédée via HTTPS", "resourceHTTPSSettingsDescription": "Configurer comment votre ressource sera accédée via HTTPS",
"domainType": "Type de domaine", "domainType": "Type de domaine",
"subdomain": "Sous-domaine", "subdomain": "Sous-domaine",
"baseDomain": "Domaine de base", "baseDomain": "Domaine racine",
"subdomnainDescription": "Le sous-domaine où votre ressource sera accessible.", "subdomnainDescription": "Le sous-domaine où votre ressource sera accessible.",
"resourceRawSettings": "Paramètres TCP/UDP", "resourceRawSettings": "Paramètres TCP/UDP",
"resourceRawSettingsDescription": "Configurer comment votre ressource sera accédée via TCP/UDP", "resourceRawSettingsDescription": "Configurer comment votre ressource sera accédée via TCP/UDP",
@ -309,7 +309,7 @@
"numberOfSites": "Nombre de sites", "numberOfSites": "Nombre de sites",
"licenseKeySearch": "Rechercher des clés de licence...", "licenseKeySearch": "Rechercher des clés de licence...",
"licenseKeyAdd": "Ajouter une clé de licence", "licenseKeyAdd": "Ajouter une clé de licence",
"type": "Type de texte", "type": "Type",
"licenseKeyRequired": "La clé de licence est requise", "licenseKeyRequired": "La clé de licence est requise",
"licenseTermsAgree": "Vous devez accepter les conditions de licence", "licenseTermsAgree": "Vous devez accepter les conditions de licence",
"licenseErrorKeyLoad": "Impossible de charger les clés de licence", "licenseErrorKeyLoad": "Impossible de charger les clés de licence",
@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.", "accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.",
"userSaved": "Utilisateur enregistré", "userSaved": "Utilisateur enregistré",
"userSavedDescription": "L'utilisateur a été mis à jour.", "userSavedDescription": "L'utilisateur a été mis à jour.",
"autoProvisioned": "Auto-provisionné",
"autoProvisionedDescription": "Permettre à cet utilisateur d'être géré automatiquement par le fournisseur d'identité",
"accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation", "accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation",
"accessControlsSubmit": "Enregistrer les contrôles d'accès", "accessControlsSubmit": "Enregistrer les contrôles d'accès",
"roles": "Rôles", "roles": "Rôles",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Format d'adresse IP invalide", "ipAddressErrorInvalidFormat": "Format d'adresse IP invalide",
"ipAddressErrorInvalidOctet": "Octet d'adresse IP invalide", "ipAddressErrorInvalidOctet": "Octet d'adresse IP invalide",
"path": "Chemin", "path": "Chemin",
"matchPath": "Chemin de correspondance",
"ipAddressRange": "Plage IP", "ipAddressRange": "Plage IP",
"rulesErrorFetch": "Échec de la récupération des règles", "rulesErrorFetch": "Échec de la récupération des règles",
"rulesErrorFetchDescription": "Une erreur s'est produite lors de la récupération des règles", "rulesErrorFetchDescription": "Une erreur s'est produite lors de la récupération des règles",
@ -595,7 +598,7 @@
"newtId": "ID Newt", "newtId": "ID Newt",
"newtSecretKey": "Clé secrète Newt", "newtSecretKey": "Clé secrète Newt",
"architecture": "Architecture", "architecture": "Architecture",
"sites": "Espaces", "sites": "Sites",
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser vos ressources internes en utilisant l'IP du pair.", "siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser vos ressources internes en utilisant l'IP du pair.",
"siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard", "siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard",
"siteWgManualConfigurationRequired": "Configuration manuelle requise", "siteWgManualConfigurationRequired": "Configuration manuelle requise",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Connecté", "idpConnectingToFinished": "Connecté",
"idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.", "idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.",
"idpErrorNotFound": "IdP introuvable", "idpErrorNotFound": "IdP introuvable",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Invitation invalide", "inviteInvalid": "Invitation invalide",
"inviteInvalidDescription": "Le lien d'invitation n'est pas valide.", "inviteInvalidDescription": "Le lien d'invitation n'est pas valide.",
"inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur", "inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Édition Professionnelle Requise", "licenseTierProfessionalRequired": "Édition Professionnelle Requise",
"licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.", "licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.",
"actionGetOrg": "Obtenir l'organisation", "actionGetOrg": "Obtenir l'organisation",
"updateOrgUser": "Mise à jour de l'utilisateur Org",
"createOrgUser": "Créer un utilisateur Org",
"actionUpdateOrg": "Mettre à jour l'organisation", "actionUpdateOrg": "Mettre à jour l'organisation",
"actionUpdateUser": "Mettre à jour l'utilisateur", "actionUpdateUser": "Mettre à jour l'utilisateur",
"actionGetUser": "Obtenir l'utilisateur", "actionGetUser": "Obtenir l'utilisateur",
@ -991,6 +998,7 @@
"actionDeleteSite": "Supprimer un site", "actionDeleteSite": "Supprimer un site",
"actionGetSite": "Obtenir un site", "actionGetSite": "Obtenir un site",
"actionListSites": "Lister les sites", "actionListSites": "Lister les sites",
"actionApplyBlueprint": "Appliquer le Plan",
"setupToken": "Jeton de configuration", "setupToken": "Jeton de configuration",
"setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.",
"setupTokenRequired": "Le jeton de configuration est requis.", "setupTokenRequired": "Le jeton de configuration est requis.",
@ -1120,7 +1128,7 @@
"sidebarOverview": "Aperçu", "sidebarOverview": "Aperçu",
"sidebarHome": "Domicile", "sidebarHome": "Domicile",
"sidebarSites": "Espaces", "sidebarSites": "Espaces",
"sidebarResources": "Ressource", "sidebarResources": "Ressources",
"sidebarAccessControl": "Contrôle d'accès", "sidebarAccessControl": "Contrôle d'accès",
"sidebarUsers": "Utilisateurs", "sidebarUsers": "Utilisateurs",
"sidebarInvitations": "Invitations", "sidebarInvitations": "Invitations",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Licence", "sidebarLicense": "Licence",
"sidebarClients": "Clients (Bêta)", "sidebarClients": "Clients (Bêta)",
"sidebarDomains": "Domaines", "sidebarDomains": "Domaines",
"enableDockerSocket": "Activer Docker Socket", "enableDockerSocket": "Activer le Plan Docker",
"enableDockerSocketDescription": "Activer la découverte Docker Socket pour remplir les informations du conteneur. Le chemin du socket doit être fourni à Newt.", "enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin de socket doit être fourni à Newt.",
"enableDockerSocketLink": "En savoir plus", "enableDockerSocketLink": "En savoir plus",
"viewDockerContainers": "Voir les conteneurs Docker", "viewDockerContainers": "Voir les conteneurs Docker",
"containersIn": "Conteneurs en {siteName}", "containersIn": "Conteneurs en {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Mise à jour disponible", "newtUpdateAvailable": "Mise à jour disponible",
"newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
"domainPickerEnterDomain": "Domaine", "domainPickerEnterDomain": "Domaine",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, ou simplement myapp", "domainPickerPlaceholder": "monapp.exemple.com",
"domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", "domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.",
"domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles", "domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles",
"domainPickerTabAll": "Tous", "domainPickerTabAll": "Tous",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocole", "editInternalResourceDialogProtocol": "Protocole",
"editInternalResourceDialogSitePort": "Port du site", "editInternalResourceDialogSitePort": "Port du site",
"editInternalResourceDialogTargetConfiguration": "Configuration de la cible", "editInternalResourceDialogTargetConfiguration": "Configuration de la cible",
"editInternalResourceDialogDestinationIP": "IP de destination",
"editInternalResourceDialogDestinationPort": "Port de destination",
"editInternalResourceDialogCancel": "Abandonner", "editInternalResourceDialogCancel": "Abandonner",
"editInternalResourceDialogSaveResource": "Enregistrer la ressource", "editInternalResourceDialogSaveResource": "Enregistrer la ressource",
"editInternalResourceDialogSuccess": "Succès", "editInternalResourceDialogSuccess": "Succès",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Port du site", "createInternalResourceDialogSitePort": "Port du site",
"createInternalResourceDialogSitePortDescription": "Utilisez ce port pour accéder à la ressource sur le site lors de la connexion avec un client.", "createInternalResourceDialogSitePortDescription": "Utilisez ce port pour accéder à la ressource sur le site lors de la connexion avec un client.",
"createInternalResourceDialogTargetConfiguration": "Configuration de la cible", "createInternalResourceDialogTargetConfiguration": "Configuration de la cible",
"createInternalResourceDialogDestinationIP": "IP de destination", "createInternalResourceDialogDestinationIPDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.",
"createInternalResourceDialogDestinationIPDescription": "L'adresse IP de la ressource sur le réseau du site.",
"createInternalResourceDialogDestinationPort": "Port de destination",
"createInternalResourceDialogDestinationPortDescription": "Le port sur l'IP de destination où la ressource est accessible.", "createInternalResourceDialogDestinationPortDescription": "Le port sur l'IP de destination où la ressource est accessible.",
"createInternalResourceDialogCancel": "Abandonner", "createInternalResourceDialogCancel": "Abandonner",
"createInternalResourceDialogCreateResource": "Créer une ressource", "createInternalResourceDialogCreateResource": "Créer une ressource",
@ -1496,5 +1500,24 @@
"convertButton": "Convertir ce noeud en auto-hébergé géré" "convertButton": "Convertir ce noeud en auto-hébergé géré"
}, },
"internationaldomaindetected": "Domaine international détecté", "internationaldomaindetected": "Domaine international détecté",
"willbestoredas": "Sera stocké comme :" "willbestoredas": "Sera stocké comme :",
"idpGoogleDescription": "Fournisseur Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "En-têtes personnalisés",
"headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.",
"domainPickerProvidedDomain": "Domaine fourni",
"domainPickerFreeProvidedDomain": "Domaine fourni gratuitement",
"domainPickerVerified": "Vérifié",
"domainPickerUnverified": "Non vérifié",
"domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.",
"domainPickerError": "Erreur",
"domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation",
"domainPickerErrorCheckAvailability": "Impossible de vérifier la disponibilité du domaine",
"domainPickerInvalidSubdomain": "Sous-domaine invalide",
"domainPickerInvalidSubdomainRemoved": "L'entrée \"{sub}\" a été supprimée car elle n'est pas valide.",
"domainPickerInvalidSubdomainCannotMakeValid": "La «{sub}» n'a pas pu être validée pour {domain}.",
"domainPickerSubdomainSanitized": "Sous-domaine nettoyé",
"domainPickerSubdomainCorrected": "\"{sub}\" a été corrigé à \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Modifier le fichier : config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Modifier le fichier : docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.", "accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.",
"userSaved": "Utente salvato", "userSaved": "Utente salvato",
"userSavedDescription": "L'utente è stato aggiornato.", "userSavedDescription": "L'utente è stato aggiornato.",
"autoProvisioned": "Auto Provisioned",
"autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità",
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione", "accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
"accessControlsSubmit": "Salva Controlli di Accesso", "accessControlsSubmit": "Salva Controlli di Accesso",
"roles": "Ruoli", "roles": "Ruoli",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Formato indirizzo IP non valido", "ipAddressErrorInvalidFormat": "Formato indirizzo IP non valido",
"ipAddressErrorInvalidOctet": "Ottetto indirizzo IP non valido", "ipAddressErrorInvalidOctet": "Ottetto indirizzo IP non valido",
"path": "Percorso", "path": "Percorso",
"matchPath": "Corrispondenza Tracciato",
"ipAddressRange": "Intervallo IP", "ipAddressRange": "Intervallo IP",
"rulesErrorFetch": "Impossibile recuperare le regole", "rulesErrorFetch": "Impossibile recuperare le regole",
"rulesErrorFetchDescription": "Si è verificato un errore durante il recupero delle regole", "rulesErrorFetchDescription": "Si è verificato un errore durante il recupero delle regole",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Connesso", "idpConnectingToFinished": "Connesso",
"idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.", "idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.",
"idpErrorNotFound": "IdP non trovato", "idpErrorNotFound": "IdP non trovato",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Invito Non Valido", "inviteInvalid": "Invito Non Valido",
"inviteInvalidDescription": "Il link di invito non è valido.", "inviteInvalidDescription": "Il link di invito non è valido.",
"inviteErrorWrongUser": "L'invito non è per questo utente", "inviteErrorWrongUser": "L'invito non è per questo utente",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Edizione Professional Richiesta", "licenseTierProfessionalRequired": "Edizione Professional Richiesta",
"licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.", "licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.",
"actionGetOrg": "Ottieni Organizzazione", "actionGetOrg": "Ottieni Organizzazione",
"updateOrgUser": "Aggiorna Utente Org",
"createOrgUser": "Crea Utente Org",
"actionUpdateOrg": "Aggiorna Organizzazione", "actionUpdateOrg": "Aggiorna Organizzazione",
"actionUpdateUser": "Aggiorna Utente", "actionUpdateUser": "Aggiorna Utente",
"actionGetUser": "Ottieni Utente", "actionGetUser": "Ottieni Utente",
@ -991,6 +998,7 @@
"actionDeleteSite": "Elimina Sito", "actionDeleteSite": "Elimina Sito",
"actionGetSite": "Ottieni Sito", "actionGetSite": "Ottieni Sito",
"actionListSites": "Elenca Siti", "actionListSites": "Elenca Siti",
"actionApplyBlueprint": "Applica Progetto",
"setupToken": "Configura Token", "setupToken": "Configura Token",
"setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.",
"setupTokenRequired": "Il token di configurazione è richiesto", "setupTokenRequired": "Il token di configurazione è richiesto",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Licenza", "sidebarLicense": "Licenza",
"sidebarClients": "Clienti (Beta)", "sidebarClients": "Clienti (Beta)",
"sidebarDomains": "Domini", "sidebarDomains": "Domini",
"enableDockerSocket": "Abilita Docker Socket", "enableDockerSocket": "Abilita Progetto Docker",
"enableDockerSocketDescription": "Abilita il rilevamento Docker Socket per popolare le informazioni del contenitore. Il percorso del socket deve essere fornito a Newt.", "enableDockerSocketDescription": "Abilita la raschiatura dell'etichetta Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito a Newt.",
"enableDockerSocketLink": "Scopri di più", "enableDockerSocketLink": "Scopri di più",
"viewDockerContainers": "Visualizza Contenitori Docker", "viewDockerContainers": "Visualizza Contenitori Docker",
"containersIn": "Contenitori in {siteName}", "containersIn": "Contenitori in {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Aggiornamento Disponibile", "newtUpdateAvailable": "Aggiornamento Disponibile",
"newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"domainPickerEnterDomain": "Dominio", "domainPickerEnterDomain": "Dominio",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, o semplicemente myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", "domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.",
"domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili", "domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili",
"domainPickerTabAll": "Tutti", "domainPickerTabAll": "Tutti",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocollo", "editInternalResourceDialogProtocol": "Protocollo",
"editInternalResourceDialogSitePort": "Porta del Sito", "editInternalResourceDialogSitePort": "Porta del Sito",
"editInternalResourceDialogTargetConfiguration": "Configurazione Target", "editInternalResourceDialogTargetConfiguration": "Configurazione Target",
"editInternalResourceDialogDestinationIP": "IP di Destinazione",
"editInternalResourceDialogDestinationPort": "Porta di Destinazione",
"editInternalResourceDialogCancel": "Annulla", "editInternalResourceDialogCancel": "Annulla",
"editInternalResourceDialogSaveResource": "Salva Risorsa", "editInternalResourceDialogSaveResource": "Salva Risorsa",
"editInternalResourceDialogSuccess": "Successo", "editInternalResourceDialogSuccess": "Successo",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Porta del Sito", "createInternalResourceDialogSitePort": "Porta del Sito",
"createInternalResourceDialogSitePortDescription": "Usa questa porta per accedere alla risorsa nel sito quando sei connesso con un client.", "createInternalResourceDialogSitePortDescription": "Usa questa porta per accedere alla risorsa nel sito quando sei connesso con un client.",
"createInternalResourceDialogTargetConfiguration": "Configurazione Target", "createInternalResourceDialogTargetConfiguration": "Configurazione Target",
"createInternalResourceDialogDestinationIP": "IP di Destinazione", "createInternalResourceDialogDestinationIPDescription": "L'indirizzo IP o hostname della risorsa nella rete del sito.",
"createInternalResourceDialogDestinationIPDescription": "L'indirizzo IP della risorsa sulla rete del sito.",
"createInternalResourceDialogDestinationPort": "Porta di Destinazione",
"createInternalResourceDialogDestinationPortDescription": "La porta sull'IP di destinazione dove la risorsa è accessibile.", "createInternalResourceDialogDestinationPortDescription": "La porta sull'IP di destinazione dove la risorsa è accessibile.",
"createInternalResourceDialogCancel": "Annulla", "createInternalResourceDialogCancel": "Annulla",
"createInternalResourceDialogCreateResource": "Crea Risorsa", "createInternalResourceDialogCreateResource": "Crea Risorsa",
@ -1496,5 +1500,24 @@
"convertButton": "Converti questo nodo in auto-ospitato gestito" "convertButton": "Converti questo nodo in auto-ospitato gestito"
}, },
"internationaldomaindetected": "Dominio Internazionale Rilevato", "internationaldomaindetected": "Dominio Internazionale Rilevato",
"willbestoredas": "Verrà conservato come:" "willbestoredas": "Verrà conservato come:",
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Intestazioni Personalizzate",
"headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.",
"domainPickerProvidedDomain": "Dominio Fornito",
"domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito",
"domainPickerVerified": "Verificato",
"domainPickerUnverified": "Non Verificato",
"domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.",
"domainPickerError": "Errore",
"domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione",
"domainPickerErrorCheckAvailability": "Impossibile verificare la disponibilità del dominio",
"domainPickerInvalidSubdomain": "Sottodominio non valido",
"domainPickerInvalidSubdomainRemoved": "L'input \"{sub}\" è stato rimosso perché non è valido.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" non può essere reso valido per {domain}.",
"domainPickerSubdomainSanitized": "Sottodominio igienizzato",
"domainPickerSubdomainCorrected": "\"{sub}\" è stato corretto in \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Modifica file: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Modifica file: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.", "accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.",
"userSaved": "사용자 저장됨", "userSaved": "사용자 저장됨",
"userSavedDescription": "사용자가 업데이트되었습니다.", "userSavedDescription": "사용자가 업데이트되었습니다.",
"autoProvisioned": "자동 프로비저닝됨",
"autoProvisionedDescription": "이 사용자가 ID 공급자에 의해 자동으로 관리될 수 있도록 허용합니다",
"accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요", "accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요",
"accessControlsSubmit": "접근 제어 저장", "accessControlsSubmit": "접근 제어 저장",
"roles": "역할", "roles": "역할",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "잘못된 IP 주소 형식", "ipAddressErrorInvalidFormat": "잘못된 IP 주소 형식",
"ipAddressErrorInvalidOctet": "유효하지 않은 IP 주소 옥텟", "ipAddressErrorInvalidOctet": "유효하지 않은 IP 주소 옥텟",
"path": "경로", "path": "경로",
"matchPath": "경로 맞춤",
"ipAddressRange": "IP 범위", "ipAddressRange": "IP 범위",
"rulesErrorFetch": "규칙을 가져오는 데 실패했습니다.", "rulesErrorFetch": "규칙을 가져오는 데 실패했습니다.",
"rulesErrorFetchDescription": "규칙을 가져오는 중 오류가 발생했습니다", "rulesErrorFetchDescription": "규칙을 가져오는 중 오류가 발생했습니다",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "연결됨", "idpConnectingToFinished": "연결됨",
"idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.", "idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.",
"idpErrorNotFound": "IdP를 찾을 수 없습니다.", "idpErrorNotFound": "IdP를 찾을 수 없습니다.",
"idpGoogleAlt": "구글",
"idpAzureAlt": "애저",
"inviteInvalid": "유효하지 않은 초대", "inviteInvalid": "유효하지 않은 초대",
"inviteInvalidDescription": "초대 링크가 유효하지 않습니다.", "inviteInvalidDescription": "초대 링크가 유효하지 않습니다.",
"inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다", "inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "전문 에디션이 필요합니다.", "licenseTierProfessionalRequired": "전문 에디션이 필요합니다.",
"licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.", "licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.",
"actionGetOrg": "조직 가져오기", "actionGetOrg": "조직 가져오기",
"updateOrgUser": "조직 사용자 업데이트",
"createOrgUser": "조직 사용자 생성",
"actionUpdateOrg": "조직 업데이트", "actionUpdateOrg": "조직 업데이트",
"actionUpdateUser": "사용자 업데이트", "actionUpdateUser": "사용자 업데이트",
"actionGetUser": "사용자 조회", "actionGetUser": "사용자 조회",
@ -991,6 +998,7 @@
"actionDeleteSite": "사이트 삭제", "actionDeleteSite": "사이트 삭제",
"actionGetSite": "사이트 가져오기", "actionGetSite": "사이트 가져오기",
"actionListSites": "사이트 목록", "actionListSites": "사이트 목록",
"actionApplyBlueprint": "청사진 적용",
"setupToken": "설정 토큰", "setupToken": "설정 토큰",
"setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.",
"setupTokenRequired": "설정 토큰이 필요합니다", "setupTokenRequired": "설정 토큰이 필요합니다",
@ -1133,8 +1141,8 @@
"sidebarLicense": "라이선스", "sidebarLicense": "라이선스",
"sidebarClients": "클라이언트 (Beta)", "sidebarClients": "클라이언트 (Beta)",
"sidebarDomains": "도메인", "sidebarDomains": "도메인",
"enableDockerSocket": "Docker 소켓 활성화", "enableDockerSocket": "Docker 청사진 활성화",
"enableDockerSocketDescription": "컨테이너 정보를 채우기 위해 Docker 소켓 검색을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.", "enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
"enableDockerSocketLink": "자세히 알아보기", "enableDockerSocketLink": "자세히 알아보기",
"viewDockerContainers": "도커 컨테이너 보기", "viewDockerContainers": "도커 컨테이너 보기",
"containersIn": "{siteName}의 컨테이너", "containersIn": "{siteName}의 컨테이너",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailable": "업데이트 가능",
"newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"domainPickerEnterDomain": "도메인", "domainPickerEnterDomain": "도메인",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, 또는 그냥 myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.",
"domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.",
"domainPickerTabAll": "모두", "domainPickerTabAll": "모두",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "프로토콜", "editInternalResourceDialogProtocol": "프로토콜",
"editInternalResourceDialogSitePort": "사이트 포트", "editInternalResourceDialogSitePort": "사이트 포트",
"editInternalResourceDialogTargetConfiguration": "대상 구성", "editInternalResourceDialogTargetConfiguration": "대상 구성",
"editInternalResourceDialogDestinationIP": "대상 IP",
"editInternalResourceDialogDestinationPort": "대상 IP의 포트",
"editInternalResourceDialogCancel": "취소", "editInternalResourceDialogCancel": "취소",
"editInternalResourceDialogSaveResource": "리소스 저장", "editInternalResourceDialogSaveResource": "리소스 저장",
"editInternalResourceDialogSuccess": "성공", "editInternalResourceDialogSuccess": "성공",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "사이트 포트", "createInternalResourceDialogSitePort": "사이트 포트",
"createInternalResourceDialogSitePortDescription": "사이트에 연결되었을 때 리소스에 접근하기 위해 이 포트를 사용합니다.", "createInternalResourceDialogSitePortDescription": "사이트에 연결되었을 때 리소스에 접근하기 위해 이 포트를 사용합니다.",
"createInternalResourceDialogTargetConfiguration": "대상 설정", "createInternalResourceDialogTargetConfiguration": "대상 설정",
"createInternalResourceDialogDestinationIP": "대상 IP", "createInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 또는 호스트 네임 주소입니다.",
"createInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 주소입니다.",
"createInternalResourceDialogDestinationPort": "대상 포트",
"createInternalResourceDialogDestinationPortDescription": "대상 IP에서 리소스에 접근할 수 있는 포트입니다.", "createInternalResourceDialogDestinationPortDescription": "대상 IP에서 리소스에 접근할 수 있는 포트입니다.",
"createInternalResourceDialogCancel": "취소", "createInternalResourceDialogCancel": "취소",
"createInternalResourceDialogCreateResource": "리소스 생성", "createInternalResourceDialogCreateResource": "리소스 생성",
@ -1496,5 +1500,24 @@
"convertButton": "이 노드를 관리 자체 호스팅으로 변환" "convertButton": "이 노드를 관리 자체 호스팅으로 변환"
}, },
"internationaldomaindetected": "국제 도메인 감지됨", "internationaldomaindetected": "국제 도메인 감지됨",
"willbestoredas": "다음으로 저장됩니다:" "willbestoredas": "다음으로 저장됩니다:",
"idpGoogleDescription": "Google OAuth2/OIDC 공급자",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자",
"customHeaders": "사용자 정의 헤더",
"headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.",
"domainPickerProvidedDomain": "제공된 도메인",
"domainPickerFreeProvidedDomain": "무료 제공된 도메인",
"domainPickerVerified": "검증됨",
"domainPickerUnverified": "검증되지 않음",
"domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.",
"domainPickerError": "오류",
"domainPickerErrorLoadDomains": "조직 도메인 로드 실패",
"domainPickerErrorCheckAvailability": "도메인 가용성 확인 실패",
"domainPickerInvalidSubdomain": "잘못된 하위 도메인",
"domainPickerInvalidSubdomainRemoved": "입력 \"{sub}\"이(가) 유효하지 않으므로 제거되었습니다.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\"을(를) {domain}에 대해 유효하게 만들 수 없습니다.",
"domainPickerSubdomainSanitized": "하위 도메인 정리됨",
"domainPickerSubdomainCorrected": "\"{sub}\"이(가) \"{sanitized}\"로 수정되었습니다",
"resourceAddEntrypointsEditFile": "파일 편집: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "파일 편집: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.", "accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.",
"userSaved": "Bruker lagret", "userSaved": "Bruker lagret",
"userSavedDescription": "Brukeren har blitt oppdatert.", "userSavedDescription": "Brukeren har blitt oppdatert.",
"autoProvisioned": "Auto avlyst",
"autoProvisionedDescription": "Tillat denne brukeren å bli automatisk administrert av en identitetsleverandør",
"accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen", "accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen",
"accessControlsSubmit": "Lagre tilgangskontroller", "accessControlsSubmit": "Lagre tilgangskontroller",
"roles": "Roller", "roles": "Roller",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Ugyldig IP-adresseformat", "ipAddressErrorInvalidFormat": "Ugyldig IP-adresseformat",
"ipAddressErrorInvalidOctet": "Ugyldig IP-adresse-oktet", "ipAddressErrorInvalidOctet": "Ugyldig IP-adresse-oktet",
"path": "Sti", "path": "Sti",
"matchPath": "Match sti",
"ipAddressRange": "IP-område", "ipAddressRange": "IP-område",
"rulesErrorFetch": "Klarte ikke å hente regler", "rulesErrorFetch": "Klarte ikke å hente regler",
"rulesErrorFetchDescription": "Det oppsto en feil under henting av regler", "rulesErrorFetchDescription": "Det oppsto en feil under henting av regler",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Tilkoblet", "idpConnectingToFinished": "Tilkoblet",
"idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.", "idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.",
"idpErrorNotFound": "IdP ikke funnet", "idpErrorNotFound": "IdP ikke funnet",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Ugyldig invitasjon", "inviteInvalid": "Ugyldig invitasjon",
"inviteInvalidDescription": "Invitasjonslenken er ugyldig.", "inviteInvalidDescription": "Invitasjonslenken er ugyldig.",
"inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren", "inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Profesjonell utgave påkrevd", "licenseTierProfessionalRequired": "Profesjonell utgave påkrevd",
"licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.", "licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.",
"actionGetOrg": "Hent organisasjon", "actionGetOrg": "Hent organisasjon",
"updateOrgUser": "Oppdater org.bruker",
"createOrgUser": "Opprett Org bruker",
"actionUpdateOrg": "Oppdater organisasjon", "actionUpdateOrg": "Oppdater organisasjon",
"actionUpdateUser": "Oppdater bruker", "actionUpdateUser": "Oppdater bruker",
"actionGetUser": "Hent bruker", "actionGetUser": "Hent bruker",
@ -991,6 +998,7 @@
"actionDeleteSite": "Slett område", "actionDeleteSite": "Slett område",
"actionGetSite": "Hent område", "actionGetSite": "Hent område",
"actionListSites": "List opp områder", "actionListSites": "List opp områder",
"actionApplyBlueprint": "Bruk blåkopi",
"setupToken": "Oppsetttoken", "setupToken": "Oppsetttoken",
"setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.",
"setupTokenRequired": "Oppsetttoken er nødvendig", "setupTokenRequired": "Oppsetttoken er nødvendig",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Lisens", "sidebarLicense": "Lisens",
"sidebarClients": "Klienter (Beta)", "sidebarClients": "Klienter (Beta)",
"sidebarDomains": "Domener", "sidebarDomains": "Domener",
"enableDockerSocket": "Aktiver Docker Socket", "enableDockerSocket": "Aktiver Docker blåkopi",
"enableDockerSocketDescription": "Aktiver Docker Socket-oppdagelse for å fylle ut containerinformasjon. Socket-stien må oppgis til Newt.", "enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.",
"enableDockerSocketLink": "Lær mer", "enableDockerSocketLink": "Lær mer",
"viewDockerContainers": "Vis Docker-containere", "viewDockerContainers": "Vis Docker-containere",
"containersIn": "Containere i {siteName}", "containersIn": "Containere i {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Oppdatering tilgjengelig", "newtUpdateAvailable": "Oppdatering tilgjengelig",
"newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
"domainPickerEnterDomain": "Domene", "domainPickerEnterDomain": "Domene",
"domainPickerPlaceholder": "minapp.eksempel.com, api.v1.mittdomene.com, eller bare minapp", "domainPickerPlaceholder": "minapp.eksempel.no",
"domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.", "domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.",
"domainPickerDescriptionSaas": "Skriv inn et fullt domene, underdomene eller bare et navn for å se tilgjengelige alternativer", "domainPickerDescriptionSaas": "Skriv inn et fullt domene, underdomene eller bare et navn for å se tilgjengelige alternativer",
"domainPickerTabAll": "Alle", "domainPickerTabAll": "Alle",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protokoll", "editInternalResourceDialogProtocol": "Protokoll",
"editInternalResourceDialogSitePort": "Områdeport", "editInternalResourceDialogSitePort": "Områdeport",
"editInternalResourceDialogTargetConfiguration": "Målkonfigurasjon", "editInternalResourceDialogTargetConfiguration": "Målkonfigurasjon",
"editInternalResourceDialogDestinationIP": "Destinasjons-IP",
"editInternalResourceDialogDestinationPort": "Destinasjonsport",
"editInternalResourceDialogCancel": "Avbryt", "editInternalResourceDialogCancel": "Avbryt",
"editInternalResourceDialogSaveResource": "Lagre ressurs", "editInternalResourceDialogSaveResource": "Lagre ressurs",
"editInternalResourceDialogSuccess": "Suksess", "editInternalResourceDialogSuccess": "Suksess",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Områdeport", "createInternalResourceDialogSitePort": "Områdeport",
"createInternalResourceDialogSitePortDescription": "Bruk denne porten for å få tilgang til ressursen på området når du er tilkoblet med en klient.", "createInternalResourceDialogSitePortDescription": "Bruk denne porten for å få tilgang til ressursen på området når du er tilkoblet med en klient.",
"createInternalResourceDialogTargetConfiguration": "Målkonfigurasjon", "createInternalResourceDialogTargetConfiguration": "Målkonfigurasjon",
"createInternalResourceDialogDestinationIP": "Destinasjons-IP", "createInternalResourceDialogDestinationIPDescription": "IP eller vertsnavn til ressursen på nettstedets nettverk.",
"createInternalResourceDialogDestinationIPDescription": "IP-adressen til ressursen på områdets nettverk.",
"createInternalResourceDialogDestinationPort": "Destinasjonsport",
"createInternalResourceDialogDestinationPortDescription": "Porten på destinasjons-IP-en der ressursen kan nås.", "createInternalResourceDialogDestinationPortDescription": "Porten på destinasjons-IP-en der ressursen kan nås.",
"createInternalResourceDialogCancel": "Avbryt", "createInternalResourceDialogCancel": "Avbryt",
"createInternalResourceDialogCreateResource": "Opprett ressurs", "createInternalResourceDialogCreateResource": "Opprett ressurs",
@ -1496,5 +1500,24 @@
"convertButton": "Konverter denne noden til manuelt bruk" "convertButton": "Konverter denne noden til manuelt bruk"
}, },
"internationaldomaindetected": "Internasjonalt domene oppdaget", "internationaldomaindetected": "Internasjonalt domene oppdaget",
"willbestoredas": "Vil bli lagret som:" "willbestoredas": "Vil bli lagret som:",
"idpGoogleDescription": "Google OAuth2/OIDC leverandør",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Egendefinerte topptekster",
"headersValidationError": "Topptekst må være i formatet: header-navn: verdi.",
"domainPickerProvidedDomain": "Gitt domene",
"domainPickerFreeProvidedDomain": "Gratis oppgitt domene",
"domainPickerVerified": "Bekreftet",
"domainPickerUnverified": "Uverifisert",
"domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.",
"domainPickerError": "Feil",
"domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener",
"domainPickerErrorCheckAvailability": "Kunne ikke kontrollere domenetilgjengelighet",
"domainPickerInvalidSubdomain": "Ugyldig underdomene",
"domainPickerInvalidSubdomainRemoved": "Inndata \"{sub}\" ble fjernet fordi det ikke er gyldig.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kunne ikke gjøres gyldig for {domain}.",
"domainPickerSubdomainSanitized": "Underdomenet som ble sanivert",
"domainPickerSubdomainCorrected": "\"{sub}\" var korrigert til \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Rediger fil: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Rediger fil: docker-compose.yml"
} }

View file

@ -38,12 +38,12 @@
"name": "naam", "name": "naam",
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"site": "Website", "site": "Referentie",
"dataIn": "Gegevens in", "dataIn": "Dataverbruik inkomend",
"dataOut": "Data Uit", "dataOut": "Dataverbruik uitgaand",
"connectionType": "Type verbinding", "connectionType": "Type verbinding",
"tunnelType": "Tunnel type", "tunnelType": "Tunnel type",
"local": "lokaal", "local": "Lokaal",
"edit": "Bewerken", "edit": "Bewerken",
"siteConfirmDelete": "Verwijderen van site bevestigen", "siteConfirmDelete": "Verwijderen van site bevestigen",
"siteDelete": "Site verwijderen", "siteDelete": "Site verwijderen",
@ -55,7 +55,7 @@
"siteCreate": "Site maken", "siteCreate": "Site maken",
"siteCreateDescription2": "Volg de onderstaande stappen om een nieuwe site aan te maken en te verbinden", "siteCreateDescription2": "Volg de onderstaande stappen om een nieuwe site aan te maken en te verbinden",
"siteCreateDescription": "Maak een nieuwe site aan om verbinding te maken met uw bronnen", "siteCreateDescription": "Maak een nieuwe site aan om verbinding te maken met uw bronnen",
"close": "Afsluiten", "close": "Sluiten",
"siteErrorCreate": "Fout bij maken site", "siteErrorCreate": "Fout bij maken site",
"siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden", "siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden",
"siteErrorCreateDefaults": "Standaardinstellingen niet gevonden", "siteErrorCreateDefaults": "Standaardinstellingen niet gevonden",
@ -90,7 +90,7 @@
"siteGeneralDescription": "Algemene instellingen voor deze site configureren", "siteGeneralDescription": "Algemene instellingen voor deze site configureren",
"siteSettingDescription": "Configureer de instellingen op uw site", "siteSettingDescription": "Configureer de instellingen op uw site",
"siteSetting": "{siteName} instellingen", "siteSetting": "{siteName} instellingen",
"siteNewtTunnel": "Nieuwstunnel (Aanbevolen)", "siteNewtTunnel": "Newttunnel (Aanbevolen)",
"siteNewtTunnelDescription": "Gemakkelijkste manier om een ingangspunt in uw netwerk te maken. Geen extra opzet.", "siteNewtTunnelDescription": "Gemakkelijkste manier om een ingangspunt in uw netwerk te maken. Geen extra opzet.",
"siteWg": "Basis WireGuard", "siteWg": "Basis WireGuard",
"siteWgDescription": "Gebruik een WireGuard client om een tunnel te bouwen. Handmatige NAT installatie vereist.", "siteWgDescription": "Gebruik een WireGuard client om een tunnel te bouwen. Handmatige NAT installatie vereist.",
@ -104,7 +104,7 @@
"siteCredentialsSave": "Uw referenties opslaan", "siteCredentialsSave": "Uw referenties opslaan",
"siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", "siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.",
"siteInfo": "Site informatie", "siteInfo": "Site informatie",
"status": "status", "status": "Status",
"shareTitle": "Beheer deellinks", "shareTitle": "Beheer deellinks",
"shareDescription": "Maak deelbare links aan om tijdelijke of permanente toegang tot uw bronnen te verlenen", "shareDescription": "Maak deelbare links aan om tijdelijke of permanente toegang tot uw bronnen te verlenen",
"shareSearch": "Zoek share links...", "shareSearch": "Zoek share links...",
@ -146,19 +146,19 @@
"never": "Nooit", "never": "Nooit",
"shareErrorSelectResource": "Selecteer een bron", "shareErrorSelectResource": "Selecteer een bron",
"resourceTitle": "Bronnen beheren", "resourceTitle": "Bronnen beheren",
"resourceDescription": "Veilige proxy's voor uw privé applicaties maken", "resourceDescription": "Veilige proxy's voor uw privé applicaties aanmaken",
"resourcesSearch": "Zoek bronnen...", "resourcesSearch": "Zoek bronnen...",
"resourceAdd": "Bron toevoegen", "resourceAdd": "Bron toevoegen",
"resourceErrorDelte": "Fout bij verwijderen document", "resourceErrorDelte": "Fout bij verwijderen document",
"authentication": "Authenticatie", "authentication": "Authenticatie",
"protected": "Beschermd", "protected": "Beveiligd",
"notProtected": "Niet beschermd", "notProtected": "Niet beveiligd",
"resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.", "resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.",
"resourceMessageConfirm": "Om te bevestigen, typ de naam van de bron hieronder.", "resourceMessageConfirm": "Om te bevestigen, typ de naam van de bron hieronder.",
"resourceQuestionRemove": "Weet u zeker dat u de resource {selectedResource} uit de organisatie wilt verwijderen?", "resourceQuestionRemove": "Weet u zeker dat u de resource {selectedResource} uit de organisatie wilt verwijderen?",
"resourceHTTP": "HTTPS bron", "resourceHTTP": "HTTPS bron",
"resourceHTTPDescription": "Proxy verzoeken aan uw app via HTTPS via een subdomein of basisdomein.", "resourceHTTPDescription": "Proxy verzoeken aan uw app via HTTPS via een subdomein of basisdomein.",
"resourceRaw": "Ruwe TCP/UDP bron", "resourceRaw": "TCP/UDP bron",
"resourceRawDescription": "Proxy verzoeken naar je app via TCP/UDP met behulp van een poortnummer.", "resourceRawDescription": "Proxy verzoeken naar je app via TCP/UDP met behulp van een poortnummer.",
"resourceCreate": "Bron maken", "resourceCreate": "Bron maken",
"resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken", "resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken",
@ -183,7 +183,7 @@
"protocolSelect": "Selecteer een protocol", "protocolSelect": "Selecteer een protocol",
"resourcePortNumber": "Nummer van poort", "resourcePortNumber": "Nummer van poort",
"resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.", "resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.",
"cancel": "annuleren", "cancel": "Annuleren",
"resourceConfig": "Configuratie tekstbouwstenen", "resourceConfig": "Configuratie tekstbouwstenen",
"resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om je TCP/UDP-bron in te stellen", "resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om je TCP/UDP-bron in te stellen",
"resourceAddEntrypoints": "Traefik: Entrypoints toevoegen", "resourceAddEntrypoints": "Traefik: Entrypoints toevoegen",
@ -212,7 +212,7 @@
"saveGeneralSettings": "Algemene instellingen opslaan", "saveGeneralSettings": "Algemene instellingen opslaan",
"saveSettings": "Instellingen opslaan", "saveSettings": "Instellingen opslaan",
"orgDangerZone": "Gevaarlijke zone", "orgDangerZone": "Gevaarlijke zone",
"orgDangerZoneDescription": "Als u deze instantie verwijdert, is er geen weg terug. Wees het alstublieft zeker.", "orgDangerZoneDescription": "Deze instantie verwijderen is onomkeerbaar. Bevestig alstublieft dat u wilt doorgaan.",
"orgDelete": "Verwijder organisatie", "orgDelete": "Verwijder organisatie",
"orgDeleteConfirm": "Bevestig Verwijderen Organisatie", "orgDeleteConfirm": "Bevestig Verwijderen Organisatie",
"orgMessageRemove": "Deze actie is onomkeerbaar en zal alle bijbehorende gegevens verwijderen.", "orgMessageRemove": "Deze actie is onomkeerbaar en zal alle bijbehorende gegevens verwijderen.",
@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.", "accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.",
"userSaved": "Gebruiker opgeslagen", "userSaved": "Gebruiker opgeslagen",
"userSavedDescription": "De gebruiker is bijgewerkt.", "userSavedDescription": "De gebruiker is bijgewerkt.",
"autoProvisioned": "Automatisch bevestigen",
"autoProvisionedDescription": "Toestaan dat deze gebruiker automatisch wordt beheerd door een identiteitsprovider",
"accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie", "accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie",
"accessControlsSubmit": "Bewaar Toegangsbesturing", "accessControlsSubmit": "Bewaar Toegangsbesturing",
"roles": "Rollen", "roles": "Rollen",
@ -499,8 +501,8 @@
"targetStickySessionsDescription": "Behoud verbindingen op hetzelfde backend doel voor hun hele sessie.", "targetStickySessionsDescription": "Behoud verbindingen op hetzelfde backend doel voor hun hele sessie.",
"methodSelect": "Selecteer methode", "methodSelect": "Selecteer methode",
"targetSubmit": "Doelwit toevoegen", "targetSubmit": "Doelwit toevoegen",
"targetNoOne": "Geen doelwitten. Voeg een doel toe via het formulier.", "targetNoOne": "Geen doel toegevoegd. Voeg deze toe via dit formulier.",
"targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.", "targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal load balancering mogelijk maken.",
"targetsSubmit": "Doelstellingen opslaan", "targetsSubmit": "Doelstellingen opslaan",
"proxyAdditional": "Extra Proxy-instellingen", "proxyAdditional": "Extra Proxy-instellingen",
"proxyAdditionalDescription": "Configureer hoe de proxy-instellingen van uw bron worden afgehandeld", "proxyAdditionalDescription": "Configureer hoe de proxy-instellingen van uw bron worden afgehandeld",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Ongeldig IP-adresformaat", "ipAddressErrorInvalidFormat": "Ongeldig IP-adresformaat",
"ipAddressErrorInvalidOctet": "Ongeldige IP adres octet", "ipAddressErrorInvalidOctet": "Ongeldige IP adres octet",
"path": "Pad", "path": "Pad",
"matchPath": "Overeenkomend pad",
"ipAddressRange": "IP Bereik", "ipAddressRange": "IP Bereik",
"rulesErrorFetch": "Regels ophalen mislukt", "rulesErrorFetch": "Regels ophalen mislukt",
"rulesErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de regels", "rulesErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de regels",
@ -595,7 +598,7 @@
"newtId": "Newt-ID", "newtId": "Newt-ID",
"newtSecretKey": "Nieuwe geheime sleutel", "newtSecretKey": "Nieuwe geheime sleutel",
"architecture": "Architectuur", "architecture": "Architectuur",
"sites": "Werkruimtes", "sites": "Verbindingen",
"siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je moet je interne bronnen aanspreken met behulp van de peer IP.", "siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je moet je interne bronnen aanspreken met behulp van de peer IP.",
"siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients", "siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients",
"siteWgManualConfigurationRequired": "Handmatige configuratie vereist", "siteWgManualConfigurationRequired": "Handmatige configuratie vereist",
@ -726,7 +729,7 @@
"idpMessageConfirm": "Om dit te bevestigen, typt u de naam van onderstaande identiteitsprovider.", "idpMessageConfirm": "Om dit te bevestigen, typt u de naam van onderstaande identiteitsprovider.",
"idpConfirmDelete": "Bevestig verwijderen Identity Provider", "idpConfirmDelete": "Bevestig verwijderen Identity Provider",
"idpDelete": "Identity Provider verwijderen", "idpDelete": "Identity Provider verwijderen",
"idp": "Identiteit aanbieders", "idp": "Identiteitsaanbieders",
"idpSearch": "Identiteitsaanbieders zoeken...", "idpSearch": "Identiteitsaanbieders zoeken...",
"idpAdd": "Identity Provider toevoegen", "idpAdd": "Identity Provider toevoegen",
"idpClientIdRequired": "Client-ID is vereist.", "idpClientIdRequired": "Client-ID is vereist.",
@ -798,7 +801,7 @@
"defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.", "defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.",
"defaultMappingsSubmit": "Standaard toewijzingen opslaan", "defaultMappingsSubmit": "Standaard toewijzingen opslaan",
"orgPoliciesEdit": "Organisatie beleid bewerken", "orgPoliciesEdit": "Organisatie beleid bewerken",
"org": "Rekening", "org": "Organisatie",
"orgSelect": "Selecteer organisatie", "orgSelect": "Selecteer organisatie",
"orgSearch": "Zoek in org", "orgSearch": "Zoek in org",
"orgNotFound": "Geen org gevonden.", "orgNotFound": "Geen org gevonden.",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Verbonden", "idpConnectingToFinished": "Verbonden",
"idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.", "idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.",
"idpErrorNotFound": "IdP niet gevonden", "idpErrorNotFound": "IdP niet gevonden",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Ongeldige uitnodiging", "inviteInvalid": "Ongeldige uitnodiging",
"inviteInvalidDescription": "Uitnodigingslink is ongeldig.", "inviteInvalidDescription": "Uitnodigingslink is ongeldig.",
"inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker", "inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker",
@ -971,10 +976,10 @@
"supportKeyEnterDescription": "Ontmoet je eigen huisdier Pangolin!", "supportKeyEnterDescription": "Ontmoet je eigen huisdier Pangolin!",
"githubUsername": "GitHub-gebruikersnaam", "githubUsername": "GitHub-gebruikersnaam",
"supportKeyInput": "Supporter Sleutel", "supportKeyInput": "Supporter Sleutel",
"supportKeyBuy": "Koop Supportersleutel", "supportKeyBuy": "Koop supportersleutel",
"logoutError": "Fout bij uitloggen", "logoutError": "Fout bij uitloggen",
"signingAs": "Ingelogd als", "signingAs": "Ingelogd als",
"serverAdmin": "Server Beheerder", "serverAdmin": "Server beheer",
"managedSelfhosted": "Beheerde Self-Hosted", "managedSelfhosted": "Beheerde Self-Hosted",
"otpEnable": "Twee-factor inschakelen", "otpEnable": "Twee-factor inschakelen",
"otpDisable": "Tweestapsverificatie uitschakelen", "otpDisable": "Tweestapsverificatie uitschakelen",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Professionele editie vereist", "licenseTierProfessionalRequired": "Professionele editie vereist",
"licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.", "licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.",
"actionGetOrg": "Krijg Organisatie", "actionGetOrg": "Krijg Organisatie",
"updateOrgUser": "Org gebruiker bijwerken",
"createOrgUser": "Org gebruiker aanmaken",
"actionUpdateOrg": "Organisatie bijwerken", "actionUpdateOrg": "Organisatie bijwerken",
"actionUpdateUser": "Gebruiker bijwerken", "actionUpdateUser": "Gebruiker bijwerken",
"actionGetUser": "Gebruiker ophalen", "actionGetUser": "Gebruiker ophalen",
@ -991,6 +998,7 @@
"actionDeleteSite": "Site verwijderen", "actionDeleteSite": "Site verwijderen",
"actionGetSite": "Site ophalen", "actionGetSite": "Site ophalen",
"actionListSites": "Sites weergeven", "actionListSites": "Sites weergeven",
"actionApplyBlueprint": "Blauwdruk toepassen",
"setupToken": "Setup Token", "setupToken": "Setup Token",
"setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.",
"setupTokenRequired": "Setup-token is vereist", "setupTokenRequired": "Setup-token is vereist",
@ -1120,7 +1128,7 @@
"sidebarOverview": "Overzicht.", "sidebarOverview": "Overzicht.",
"sidebarHome": "Startpagina", "sidebarHome": "Startpagina",
"sidebarSites": "Werkruimtes", "sidebarSites": "Werkruimtes",
"sidebarResources": "Hulpmiddelen", "sidebarResources": "Bronnen",
"sidebarAccessControl": "Toegangs controle", "sidebarAccessControl": "Toegangs controle",
"sidebarUsers": "Gebruikers", "sidebarUsers": "Gebruikers",
"sidebarInvitations": "Uitnodigingen", "sidebarInvitations": "Uitnodigingen",
@ -1133,13 +1141,13 @@
"sidebarLicense": "Licentie", "sidebarLicense": "Licentie",
"sidebarClients": "Clients (Bèta)", "sidebarClients": "Clients (Bèta)",
"sidebarDomains": "Domeinen", "sidebarDomains": "Domeinen",
"enableDockerSocket": "Docker Socket inschakelen", "enableDockerSocket": "Schakel Docker Blauwdruk in",
"enableDockerSocketDescription": "Docker Socket-ontdekking inschakelen voor het invullen van containerinformatie. Socket-pad moet aan Newt worden verstrekt.", "enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.",
"enableDockerSocketLink": "Meer informatie", "enableDockerSocketLink": "Meer informatie",
"viewDockerContainers": "Bekijk Docker containers", "viewDockerContainers": "Bekijk Docker containers",
"containersIn": "Containers in {siteName}", "containersIn": "Containers in {siteName}",
"selectContainerDescription": "Selecteer een container om als hostnaam voor dit doel te gebruiken. Klik op een poort om een poort te gebruiken.", "selectContainerDescription": "Selecteer een container om als hostnaam voor dit doel te gebruiken. Klik op een poort om een poort te gebruiken.",
"containerName": "naam", "containerName": "Naam",
"containerImage": "Afbeelding", "containerImage": "Afbeelding",
"containerState": "Provincie", "containerState": "Provincie",
"containerNetworks": "Netwerken", "containerNetworks": "Netwerken",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Update beschikbaar", "newtUpdateAvailable": "Update beschikbaar",
"newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
"domainPickerEnterDomain": "Domein", "domainPickerEnterDomain": "Domein",
"domainPickerPlaceholder": "mijnapp.voorbeeld.com, api.v1.mijndomein.com, of gewoon mijnapp", "domainPickerPlaceholder": "mijnapp.voorbeeld.nl",
"domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.", "domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.",
"domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien", "domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien",
"domainPickerTabAll": "Alles", "domainPickerTabAll": "Alles",
@ -1341,7 +1349,7 @@
"olmId": "Olm ID", "olmId": "Olm ID",
"olmSecretKey": "Olm Geheime Sleutel", "olmSecretKey": "Olm Geheime Sleutel",
"clientCredentialsSave": "Uw referenties opslaan", "clientCredentialsSave": "Uw referenties opslaan",
"clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", "clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer deze naar een veilige plek.",
"generalSettingsDescription": "Configureer de algemene instellingen voor deze client", "generalSettingsDescription": "Configureer de algemene instellingen voor deze client",
"clientUpdated": "Klant bijgewerkt ", "clientUpdated": "Klant bijgewerkt ",
"clientUpdatedDescription": "De client is bijgewerkt.", "clientUpdatedDescription": "De client is bijgewerkt.",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogProtocol": "Protocol",
"editInternalResourceDialogSitePort": "Site Poort", "editInternalResourceDialogSitePort": "Site Poort",
"editInternalResourceDialogTargetConfiguration": "Doelconfiguratie", "editInternalResourceDialogTargetConfiguration": "Doelconfiguratie",
"editInternalResourceDialogDestinationIP": "Bestemming IP",
"editInternalResourceDialogDestinationPort": "Bestemmingspoort",
"editInternalResourceDialogCancel": "Annuleren", "editInternalResourceDialogCancel": "Annuleren",
"editInternalResourceDialogSaveResource": "Sla bron op", "editInternalResourceDialogSaveResource": "Sla bron op",
"editInternalResourceDialogSuccess": "Succes", "editInternalResourceDialogSuccess": "Succes",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Site Poort", "createInternalResourceDialogSitePort": "Site Poort",
"createInternalResourceDialogSitePortDescription": "Gebruik deze poort om toegang te krijgen tot de bron op de site wanneer verbonden met een client.", "createInternalResourceDialogSitePortDescription": "Gebruik deze poort om toegang te krijgen tot de bron op de site wanneer verbonden met een client.",
"createInternalResourceDialogTargetConfiguration": "Doelconfiguratie", "createInternalResourceDialogTargetConfiguration": "Doelconfiguratie",
"createInternalResourceDialogDestinationIP": "Bestemming IP", "createInternalResourceDialogDestinationIPDescription": "Het IP of hostnaam adres van de bron op het netwerk van de site.",
"createInternalResourceDialogDestinationIPDescription": "Het IP-adres van de bron op het netwerk van de site.",
"createInternalResourceDialogDestinationPort": "Bestemmingspoort",
"createInternalResourceDialogDestinationPortDescription": "De poort op het bestemmings-IP waar de bron toegankelijk is.", "createInternalResourceDialogDestinationPortDescription": "De poort op het bestemmings-IP waar de bron toegankelijk is.",
"createInternalResourceDialogCancel": "Annuleren", "createInternalResourceDialogCancel": "Annuleren",
"createInternalResourceDialogCreateResource": "Bron aanmaken", "createInternalResourceDialogCreateResource": "Bron aanmaken",
@ -1496,5 +1500,24 @@
"convertButton": "Converteer deze node naar Beheerde Zelf-Hosted" "convertButton": "Converteer deze node naar Beheerde Zelf-Hosted"
}, },
"internationaldomaindetected": "Internationaal Domein Gedetecteerd", "internationaldomaindetected": "Internationaal Domein Gedetecteerd",
"willbestoredas": "Zal worden opgeslagen als:" "willbestoredas": "Zal worden opgeslagen als:",
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Aangepaste headers",
"headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.",
"domainPickerProvidedDomain": "Opgegeven domein",
"domainPickerFreeProvidedDomain": "Gratis verstrekt domein",
"domainPickerVerified": "Geverifieerd",
"domainPickerUnverified": "Ongeverifieerd",
"domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.",
"domainPickerError": "Foutmelding",
"domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen",
"domainPickerErrorCheckAvailability": "Kan domein beschikbaarheid niet controleren",
"domainPickerInvalidSubdomain": "Ongeldig subdomein",
"domainPickerInvalidSubdomainRemoved": "De invoer \"{sub}\" is verwijderd omdat het niet geldig is.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kon niet geldig worden gemaakt voor {domain}.",
"domainPickerSubdomainSanitized": "Subdomein gesaniseerd",
"domainPickerSubdomainCorrected": "\"{sub}\" was gecorrigeerd op \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Bestand bewerken: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Bestand bewerken: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.", "accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.",
"userSaved": "Użytkownik zapisany", "userSaved": "Użytkownik zapisany",
"userSavedDescription": "Użytkownik został zaktualizowany.", "userSavedDescription": "Użytkownik został zaktualizowany.",
"autoProvisioned": "Przesłane automatycznie",
"autoProvisionedDescription": "Pozwól temu użytkownikowi na automatyczne zarządzanie przez dostawcę tożsamości",
"accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji", "accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji",
"accessControlsSubmit": "Zapisz kontrole dostępu", "accessControlsSubmit": "Zapisz kontrole dostępu",
"roles": "Role", "roles": "Role",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Nieprawidłowy format adresu IP", "ipAddressErrorInvalidFormat": "Nieprawidłowy format adresu IP",
"ipAddressErrorInvalidOctet": "Nieprawidłowy oktet adresu IP", "ipAddressErrorInvalidOctet": "Nieprawidłowy oktet adresu IP",
"path": "Ścieżka", "path": "Ścieżka",
"matchPath": "Ścieżka dopasowania",
"ipAddressRange": "Zakres IP", "ipAddressRange": "Zakres IP",
"rulesErrorFetch": "Nie udało się pobrać reguł", "rulesErrorFetch": "Nie udało się pobrać reguł",
"rulesErrorFetchDescription": "Wystąpił błąd podczas pobierania reguł", "rulesErrorFetchDescription": "Wystąpił błąd podczas pobierania reguł",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Połączono", "idpConnectingToFinished": "Połączono",
"idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.", "idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.",
"idpErrorNotFound": "Nie znaleziono IdP", "idpErrorNotFound": "Nie znaleziono IdP",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Nieprawidłowe zaproszenie", "inviteInvalid": "Nieprawidłowe zaproszenie",
"inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.", "inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.",
"inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika", "inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Wymagana edycja Professional", "licenseTierProfessionalRequired": "Wymagana edycja Professional",
"licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.", "licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.",
"actionGetOrg": "Pobierz organizację", "actionGetOrg": "Pobierz organizację",
"updateOrgUser": "Aktualizuj użytkownika Org",
"createOrgUser": "Utwórz użytkownika Org",
"actionUpdateOrg": "Aktualizuj organizację", "actionUpdateOrg": "Aktualizuj organizację",
"actionUpdateUser": "Zaktualizuj użytkownika", "actionUpdateUser": "Zaktualizuj użytkownika",
"actionGetUser": "Pobierz użytkownika", "actionGetUser": "Pobierz użytkownika",
@ -991,6 +998,7 @@
"actionDeleteSite": "Usuń witrynę", "actionDeleteSite": "Usuń witrynę",
"actionGetSite": "Pobierz witrynę", "actionGetSite": "Pobierz witrynę",
"actionListSites": "Lista witryn", "actionListSites": "Lista witryn",
"actionApplyBlueprint": "Zastosuj schemat",
"setupToken": "Skonfiguruj token", "setupToken": "Skonfiguruj token",
"setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.",
"setupTokenRequired": "Wymagany jest token konfiguracji", "setupTokenRequired": "Wymagany jest token konfiguracji",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Licencja", "sidebarLicense": "Licencja",
"sidebarClients": "Klienci (Beta)", "sidebarClients": "Klienci (Beta)",
"sidebarDomains": "Domeny", "sidebarDomains": "Domeny",
"enableDockerSocket": "Włącz gniazdo dokera", "enableDockerSocket": "Włącz schemat dokera",
"enableDockerSocketDescription": "Włącz wykrywanie Docker Socket w celu wypełnienia informacji o kontenerach. Ścieżka gniazda musi być dostarczona do Newt.", "enableDockerSocketDescription": "Włącz etykietowanie kieszeni dokującej dla etykiet schematów. Ścieżka do gniazda musi być dostarczona do Newt.",
"enableDockerSocketLink": "Dowiedz się więcej", "enableDockerSocketLink": "Dowiedz się więcej",
"viewDockerContainers": "Zobacz kontenery dokujące", "viewDockerContainers": "Zobacz kontenery dokujące",
"containersIn": "Pojemniki w {siteName}", "containersIn": "Pojemniki w {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Dostępna aktualizacja", "newtUpdateAvailable": "Dostępna aktualizacja",
"newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", "newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.",
"domainPickerEnterDomain": "Domena", "domainPickerEnterDomain": "Domena",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com lub po prostu myapp", "domainPickerPlaceholder": "mojapp.example.com",
"domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.",
"domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje", "domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje",
"domainPickerTabAll": "Wszystko", "domainPickerTabAll": "Wszystko",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protokół", "editInternalResourceDialogProtocol": "Protokół",
"editInternalResourceDialogSitePort": "Port witryny", "editInternalResourceDialogSitePort": "Port witryny",
"editInternalResourceDialogTargetConfiguration": "Konfiguracja celu", "editInternalResourceDialogTargetConfiguration": "Konfiguracja celu",
"editInternalResourceDialogDestinationIP": "IP docelowe",
"editInternalResourceDialogDestinationPort": "Port docelowy",
"editInternalResourceDialogCancel": "Anuluj", "editInternalResourceDialogCancel": "Anuluj",
"editInternalResourceDialogSaveResource": "Zapisz zasób", "editInternalResourceDialogSaveResource": "Zapisz zasób",
"editInternalResourceDialogSuccess": "Sukces", "editInternalResourceDialogSuccess": "Sukces",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Port witryny", "createInternalResourceDialogSitePort": "Port witryny",
"createInternalResourceDialogSitePortDescription": "Użyj tego portu, aby uzyskać dostęp do zasobu na stronie, gdy połączony z klientem.", "createInternalResourceDialogSitePortDescription": "Użyj tego portu, aby uzyskać dostęp do zasobu na stronie, gdy połączony z klientem.",
"createInternalResourceDialogTargetConfiguration": "Konfiguracja celu", "createInternalResourceDialogTargetConfiguration": "Konfiguracja celu",
"createInternalResourceDialogDestinationIP": "IP docelowe", "createInternalResourceDialogDestinationIPDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.",
"createInternalResourceDialogDestinationIPDescription": "Adres IP zasobu w sieci strony.",
"createInternalResourceDialogDestinationPort": "Port docelowy",
"createInternalResourceDialogDestinationPortDescription": "Port na docelowym IP, gdzie zasób jest dostępny.", "createInternalResourceDialogDestinationPortDescription": "Port na docelowym IP, gdzie zasób jest dostępny.",
"createInternalResourceDialogCancel": "Anuluj", "createInternalResourceDialogCancel": "Anuluj",
"createInternalResourceDialogCreateResource": "Utwórz zasób", "createInternalResourceDialogCreateResource": "Utwórz zasób",
@ -1496,5 +1500,24 @@
"convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie" "convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie"
}, },
"internationaldomaindetected": "Wykryto międzynarodową domenę", "internationaldomaindetected": "Wykryto międzynarodową domenę",
"willbestoredas": "Będą przechowywane jako:" "willbestoredas": "Będą przechowywane jako:",
"idpGoogleDescription": "Dostawca Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Niestandardowe nagłówki",
"headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.",
"domainPickerProvidedDomain": "Dostarczona domena",
"domainPickerFreeProvidedDomain": "Darmowa oferowana domena",
"domainPickerVerified": "Zweryfikowano",
"domainPickerUnverified": "Niezweryfikowane",
"domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.",
"domainPickerError": "Błąd",
"domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji",
"domainPickerErrorCheckAvailability": "Nie udało się sprawdzić dostępności domeny",
"domainPickerInvalidSubdomain": "Nieprawidłowa subdomena",
"domainPickerInvalidSubdomainRemoved": "Wejście \"{sub}\" zostało usunięte, ponieważ jest nieprawidłowe.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nie może być poprawne dla {domain}.",
"domainPickerSubdomainSanitized": "Poddomena oczyszczona",
"domainPickerSubdomainCorrected": "\"{sub}\" został skorygowany do \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Edytuj plik: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Edytuj plik: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.", "accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar usuário à função.",
"userSaved": "Usuário salvo", "userSaved": "Usuário salvo",
"userSavedDescription": "O usuário foi atualizado.", "userSavedDescription": "O usuário foi atualizado.",
"autoProvisioned": "Auto provisionado",
"autoProvisionedDescription": "Permitir que este usuário seja gerenciado automaticamente pelo provedor de identidade",
"accessControlsDescription": "Gerencie o que este usuário pode acessar e fazer na organização", "accessControlsDescription": "Gerencie o que este usuário pode acessar e fazer na organização",
"accessControlsSubmit": "Salvar Controles de Acesso", "accessControlsSubmit": "Salvar Controles de Acesso",
"roles": "Funções", "roles": "Funções",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Formato de endereço IP inválido", "ipAddressErrorInvalidFormat": "Formato de endereço IP inválido",
"ipAddressErrorInvalidOctet": "Octeto de endereço IP inválido", "ipAddressErrorInvalidOctet": "Octeto de endereço IP inválido",
"path": "Caminho", "path": "Caminho",
"matchPath": "Correspondência de caminho",
"ipAddressRange": "Faixa de IP", "ipAddressRange": "Faixa de IP",
"rulesErrorFetch": "Falha ao buscar regras", "rulesErrorFetch": "Falha ao buscar regras",
"rulesErrorFetchDescription": "Ocorreu um erro ao buscar regras", "rulesErrorFetchDescription": "Ocorreu um erro ao buscar regras",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Conectado", "idpConnectingToFinished": "Conectado",
"idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.", "idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.",
"idpErrorNotFound": "IdP não encontrado", "idpErrorNotFound": "IdP não encontrado",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Convite Inválido", "inviteInvalid": "Convite Inválido",
"inviteInvalidDescription": "O link do convite é inválido.", "inviteInvalidDescription": "O link do convite é inválido.",
"inviteErrorWrongUser": "O convite não é para este usuário", "inviteErrorWrongUser": "O convite não é para este usuário",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Edição Profissional Necessária", "licenseTierProfessionalRequired": "Edição Profissional Necessária",
"licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.", "licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.",
"actionGetOrg": "Obter Organização", "actionGetOrg": "Obter Organização",
"updateOrgUser": "Atualizar usuário Org",
"createOrgUser": "Criar usuário Org",
"actionUpdateOrg": "Atualizar Organização", "actionUpdateOrg": "Atualizar Organização",
"actionUpdateUser": "Atualizar Usuário", "actionUpdateUser": "Atualizar Usuário",
"actionGetUser": "Obter Usuário", "actionGetUser": "Obter Usuário",
@ -991,6 +998,7 @@
"actionDeleteSite": "Eliminar Site", "actionDeleteSite": "Eliminar Site",
"actionGetSite": "Obter Site", "actionGetSite": "Obter Site",
"actionListSites": "Listar Sites", "actionListSites": "Listar Sites",
"actionApplyBlueprint": "Aplicar Diagrama",
"setupToken": "Configuração do Token", "setupToken": "Configuração do Token",
"setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenDescription": "Digite o token de configuração do console do servidor.",
"setupTokenRequired": "Token de configuração é necessário", "setupTokenRequired": "Token de configuração é necessário",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Tipo:", "sidebarLicense": "Tipo:",
"sidebarClients": "Clientes (Beta)", "sidebarClients": "Clientes (Beta)",
"sidebarDomains": "Domínios", "sidebarDomains": "Domínios",
"enableDockerSocket": "Habilitar Docker Socket", "enableDockerSocket": "Habilitar o Diagrama Docker",
"enableDockerSocketDescription": "Ativar a descoberta do Docker Socket para preencher informações do contêiner. O caminho do socket deve ser fornecido ao Newt.", "enableDockerSocketDescription": "Ativar a scraping de rótulo Docker para rótulos de diagramas. Caminho de Socket deve ser fornecido para Newt.",
"enableDockerSocketLink": "Saiba mais", "enableDockerSocketLink": "Saiba mais",
"viewDockerContainers": "Ver contêineres Docker", "viewDockerContainers": "Ver contêineres Docker",
"containersIn": "Contêineres em {siteName}", "containersIn": "Contêineres em {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Nova Atualização Disponível", "newtUpdateAvailable": "Nova Atualização Disponível",
"newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.", "newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.",
"domainPickerEnterDomain": "Domínio", "domainPickerEnterDomain": "Domínio",
"domainPickerPlaceholder": "meuapp.exemplo.com, api.v1.meudominio.com, ou apenas meuapp", "domainPickerPlaceholder": "myapp.exemplo.com",
"domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.", "domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.",
"domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis", "domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis",
"domainPickerTabAll": "Todos", "domainPickerTabAll": "Todos",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protocolo", "editInternalResourceDialogProtocol": "Protocolo",
"editInternalResourceDialogSitePort": "Porta do Site", "editInternalResourceDialogSitePort": "Porta do Site",
"editInternalResourceDialogTargetConfiguration": "Configuração do Alvo", "editInternalResourceDialogTargetConfiguration": "Configuração do Alvo",
"editInternalResourceDialogDestinationIP": "IP de Destino",
"editInternalResourceDialogDestinationPort": "Porta de Destino",
"editInternalResourceDialogCancel": "Cancelar", "editInternalResourceDialogCancel": "Cancelar",
"editInternalResourceDialogSaveResource": "Salvar Recurso", "editInternalResourceDialogSaveResource": "Salvar Recurso",
"editInternalResourceDialogSuccess": "Sucesso", "editInternalResourceDialogSuccess": "Sucesso",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Porta do Site", "createInternalResourceDialogSitePort": "Porta do Site",
"createInternalResourceDialogSitePortDescription": "Use esta porta para acessar o recurso no site quando conectado com um cliente.", "createInternalResourceDialogSitePortDescription": "Use esta porta para acessar o recurso no site quando conectado com um cliente.",
"createInternalResourceDialogTargetConfiguration": "Configuração do Alvo", "createInternalResourceDialogTargetConfiguration": "Configuração do Alvo",
"createInternalResourceDialogDestinationIP": "IP de Destino", "createInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.",
"createInternalResourceDialogDestinationIPDescription": "O endereço IP do recurso na rede do site.",
"createInternalResourceDialogDestinationPort": "Porta de Destino",
"createInternalResourceDialogDestinationPortDescription": "A porta no IP de destino onde o recurso está acessível.", "createInternalResourceDialogDestinationPortDescription": "A porta no IP de destino onde o recurso está acessível.",
"createInternalResourceDialogCancel": "Cancelar", "createInternalResourceDialogCancel": "Cancelar",
"createInternalResourceDialogCreateResource": "Criar Recurso", "createInternalResourceDialogCreateResource": "Criar Recurso",
@ -1496,5 +1500,24 @@
"convertButton": "Converter este nó para Auto-Hospedado Gerenciado" "convertButton": "Converter este nó para Auto-Hospedado Gerenciado"
}, },
"internationaldomaindetected": "Domínio Internacional Detectado", "internationaldomaindetected": "Domínio Internacional Detectado",
"willbestoredas": "Será armazenado como:" "willbestoredas": "Será armazenado como:",
"idpGoogleDescription": "Provedor Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Cabeçalhos Personalizados",
"headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.",
"domainPickerProvidedDomain": "Domínio fornecido",
"domainPickerFreeProvidedDomain": "Domínio fornecido grátis",
"domainPickerVerified": "Verificada",
"domainPickerUnverified": "Não verificado",
"domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.",
"domainPickerError": "ERRO",
"domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização",
"domainPickerErrorCheckAvailability": "Não foi possível verificar a disponibilidade do domínio",
"domainPickerInvalidSubdomain": "Subdomínio inválido",
"domainPickerInvalidSubdomainRemoved": "A entrada \"{sub}\" foi removida porque ela não é válida.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" não pôde ser válido para {domain}.",
"domainPickerSubdomainSanitized": "Subdomínio banalizado",
"domainPickerSubdomainCorrected": "\"{sub}\" foi corrigido para \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Editar arquivo: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Editar arquivo: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.", "accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.",
"userSaved": "Пользователь сохранён", "userSaved": "Пользователь сохранён",
"userSavedDescription": "Пользователь был обновлён.", "userSavedDescription": "Пользователь был обновлён.",
"autoProvisioned": "Автоподбор",
"autoProvisionedDescription": "Разрешить автоматическое управление этим пользователем",
"accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации", "accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации",
"accessControlsSubmit": "Сохранить контроль доступа", "accessControlsSubmit": "Сохранить контроль доступа",
"roles": "Роли", "roles": "Роли",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Неверный формат IP адреса", "ipAddressErrorInvalidFormat": "Неверный формат IP адреса",
"ipAddressErrorInvalidOctet": "Неверный октет IP адреса", "ipAddressErrorInvalidOctet": "Неверный октет IP адреса",
"path": "Путь", "path": "Путь",
"matchPath": "Путь матча",
"ipAddressRange": "Диапазон IP", "ipAddressRange": "Диапазон IP",
"rulesErrorFetch": "Не удалось получить правила", "rulesErrorFetch": "Не удалось получить правила",
"rulesErrorFetchDescription": "Произошла ошибка при получении правил", "rulesErrorFetchDescription": "Произошла ошибка при получении правил",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Подключено", "idpConnectingToFinished": "Подключено",
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.", "idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
"idpErrorNotFound": "IdP не найден", "idpErrorNotFound": "IdP не найден",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Недействительное приглашение", "inviteInvalid": "Недействительное приглашение",
"inviteInvalidDescription": "Ссылка на приглашение недействительна.", "inviteInvalidDescription": "Ссылка на приглашение недействительна.",
"inviteErrorWrongUser": "Приглашение не для этого пользователя", "inviteErrorWrongUser": "Приглашение не для этого пользователя",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Требуется профессиональная версия", "licenseTierProfessionalRequired": "Требуется профессиональная версия",
"licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.", "licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.",
"actionGetOrg": "Получить организацию", "actionGetOrg": "Получить организацию",
"updateOrgUser": "Обновить пользователя Org",
"createOrgUser": "Создать пользователя Org",
"actionUpdateOrg": "Обновить организацию", "actionUpdateOrg": "Обновить организацию",
"actionUpdateUser": "Обновить пользователя", "actionUpdateUser": "Обновить пользователя",
"actionGetUser": "Получить пользователя", "actionGetUser": "Получить пользователя",
@ -991,6 +998,7 @@
"actionDeleteSite": "Удалить сайт", "actionDeleteSite": "Удалить сайт",
"actionGetSite": "Получить сайт", "actionGetSite": "Получить сайт",
"actionListSites": "Список сайтов", "actionListSites": "Список сайтов",
"actionApplyBlueprint": "Применить чертёж",
"setupToken": "Код настройки", "setupToken": "Код настройки",
"setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenDescription": "Введите токен настройки из консоли сервера.",
"setupTokenRequired": "Токен настройки обязателен", "setupTokenRequired": "Токен настройки обязателен",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Лицензия", "sidebarLicense": "Лицензия",
"sidebarClients": "Клиенты (бета)", "sidebarClients": "Клиенты (бета)",
"sidebarDomains": "Домены", "sidebarDomains": "Домены",
"enableDockerSocket": "Включить Docker Socket", "enableDockerSocket": "Включить чертёж Docker",
"enableDockerSocketDescription": "Включить обнаружение Docker Socket для заполнения информации о контейнерах. Путь к сокету должен быть предоставлен Newt.", "enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
"enableDockerSocketLink": "Узнать больше", "enableDockerSocketLink": "Узнать больше",
"viewDockerContainers": "Просмотр контейнеров Docker", "viewDockerContainers": "Просмотр контейнеров Docker",
"containersIn": "Контейнеры в {siteName}", "containersIn": "Контейнеры в {siteName}",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailable": "Доступно обновление",
"newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.",
"domainPickerEnterDomain": "Домен", "domainPickerEnterDomain": "Домен",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, или просто myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.",
"domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции", "domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции",
"domainPickerTabAll": "Все", "domainPickerTabAll": "Все",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Протокол", "editInternalResourceDialogProtocol": "Протокол",
"editInternalResourceDialogSitePort": "Порт сайта", "editInternalResourceDialogSitePort": "Порт сайта",
"editInternalResourceDialogTargetConfiguration": "Настройка цели", "editInternalResourceDialogTargetConfiguration": "Настройка цели",
"editInternalResourceDialogDestinationIP": "Целевая IP",
"editInternalResourceDialogDestinationPort": "Целевой порт",
"editInternalResourceDialogCancel": "Отмена", "editInternalResourceDialogCancel": "Отмена",
"editInternalResourceDialogSaveResource": "Сохранить ресурс", "editInternalResourceDialogSaveResource": "Сохранить ресурс",
"editInternalResourceDialogSuccess": "Успешно", "editInternalResourceDialogSuccess": "Успешно",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Порт сайта", "createInternalResourceDialogSitePort": "Порт сайта",
"createInternalResourceDialogSitePortDescription": "Используйте этот порт для доступа к ресурсу на сайте при подключении с клиентом.", "createInternalResourceDialogSitePortDescription": "Используйте этот порт для доступа к ресурсу на сайте при подключении с клиентом.",
"createInternalResourceDialogTargetConfiguration": "Настройка цели", "createInternalResourceDialogTargetConfiguration": "Настройка цели",
"createInternalResourceDialogDestinationIP": "Целевая IP", "createInternalResourceDialogDestinationIPDescription": "IP или адрес хоста ресурса в сети сайта.",
"createInternalResourceDialogDestinationIPDescription": "IP-адрес ресурса в сети сайта.",
"createInternalResourceDialogDestinationPort": "Целевой порт",
"createInternalResourceDialogDestinationPortDescription": "Порт на IP-адресе назначения, где доступен ресурс.", "createInternalResourceDialogDestinationPortDescription": "Порт на IP-адресе назначения, где доступен ресурс.",
"createInternalResourceDialogCancel": "Отмена", "createInternalResourceDialogCancel": "Отмена",
"createInternalResourceDialogCreateResource": "Создать ресурс", "createInternalResourceDialogCreateResource": "Создать ресурс",
@ -1496,5 +1500,24 @@
"convertButton": "Конвертировать этот узел в управляемый себе-хост" "convertButton": "Конвертировать этот узел в управляемый себе-хост"
}, },
"internationaldomaindetected": "Обнаружен международный домен", "internationaldomaindetected": "Обнаружен международный домен",
"willbestoredas": "Будет храниться как:" "willbestoredas": "Будет храниться как:",
"idpGoogleDescription": "Google OAuth2/OIDC провайдер",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Пользовательские заголовки",
"headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.",
"domainPickerProvidedDomain": "Домен предоставлен",
"domainPickerFreeProvidedDomain": "Бесплатный домен",
"domainPickerVerified": "Подтверждено",
"domainPickerUnverified": "Не подтверждено",
"domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.",
"domainPickerError": "Ошибка",
"domainPickerErrorLoadDomains": "Не удалось загрузить домены организации",
"domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена",
"domainPickerInvalidSubdomain": "Неверный поддомен",
"domainPickerInvalidSubdomainRemoved": "Ввод \"{sub}\" был удален, потому что он недействителен.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не может быть действительным для {domain}.",
"domainPickerSubdomainSanitized": "Субдомен очищен",
"domainPickerSubdomainCorrected": "\"{sub}\" был исправлен на \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "Редактировать файл: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Редактировать файл: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.", "accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.",
"userSaved": "Kullanıcı kaydedildi", "userSaved": "Kullanıcı kaydedildi",
"userSavedDescription": "Kullanıcı güncellenmiştir.", "userSavedDescription": "Kullanıcı güncellenmiştir.",
"autoProvisioned": "Otomatik Sağlandı",
"autoProvisionedDescription": "Bu kullanıcının kimlik sağlayıcısı tarafından otomatik olarak yönetilmesine izin ver",
"accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin", "accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin",
"accessControlsSubmit": "Erişim Kontrollerini Kaydet", "accessControlsSubmit": "Erişim Kontrollerini Kaydet",
"roles": "Roller", "roles": "Roller",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "Geçersiz IP adresi formatı", "ipAddressErrorInvalidFormat": "Geçersiz IP adresi formatı",
"ipAddressErrorInvalidOctet": "Geçersiz IP adresi okteti", "ipAddressErrorInvalidOctet": "Geçersiz IP adresi okteti",
"path": "Yol", "path": "Yol",
"matchPath": "Yol Eşleştir",
"ipAddressRange": "IP Aralığı", "ipAddressRange": "IP Aralığı",
"rulesErrorFetch": "Kurallar alınamadı", "rulesErrorFetch": "Kurallar alınamadı",
"rulesErrorFetchDescription": "Kurallar alınırken bir hata oluştu", "rulesErrorFetchDescription": "Kurallar alınırken bir hata oluştu",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "Bağlandı", "idpConnectingToFinished": "Bağlandı",
"idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.", "idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.",
"idpErrorNotFound": "IdP bulunamadı", "idpErrorNotFound": "IdP bulunamadı",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "Geçersiz Davet", "inviteInvalid": "Geçersiz Davet",
"inviteInvalidDescription": "Davet bağlantısı geçersiz.", "inviteInvalidDescription": "Davet bağlantısı geçersiz.",
"inviteErrorWrongUser": "Davet bu kullanıcı için değil", "inviteErrorWrongUser": "Davet bu kullanıcı için değil",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir", "licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir",
"licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.", "licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.",
"actionGetOrg": "Kuruluşu Al", "actionGetOrg": "Kuruluşu Al",
"updateOrgUser": "Organizasyon Kullanıcısını Güncelle",
"createOrgUser": "Organizasyon Kullanıcısı Oluştur",
"actionUpdateOrg": "Kuruluşu Güncelle", "actionUpdateOrg": "Kuruluşu Güncelle",
"actionUpdateUser": "Kullanıcıyı Güncelle", "actionUpdateUser": "Kullanıcıyı Güncelle",
"actionGetUser": "Kullanıcıyı Getir", "actionGetUser": "Kullanıcıyı Getir",
@ -991,6 +998,7 @@
"actionDeleteSite": "Siteyi Sil", "actionDeleteSite": "Siteyi Sil",
"actionGetSite": "Siteyi Al", "actionGetSite": "Siteyi Al",
"actionListSites": "Siteleri Listele", "actionListSites": "Siteleri Listele",
"actionApplyBlueprint": "Planı Uygula",
"setupToken": "Kurulum Simgesi", "setupToken": "Kurulum Simgesi",
"setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.",
"setupTokenRequired": "Kurulum simgesi gerekli", "setupTokenRequired": "Kurulum simgesi gerekli",
@ -1133,8 +1141,8 @@
"sidebarLicense": "Lisans", "sidebarLicense": "Lisans",
"sidebarClients": "Müşteriler (Beta)", "sidebarClients": "Müşteriler (Beta)",
"sidebarDomains": "Alan Adları", "sidebarDomains": "Alan Adları",
"enableDockerSocket": "Docker Soketi Etkinleştir", "enableDockerSocket": "Docker Soketini Etkinleştir",
"enableDockerSocketDescription": "Konteyner bilgilerini doldurmak için Docker Socket keşfini etkinleştirin. Socket yolu Newt'e sağlanmalıdır.", "enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Newt'e soket yolu sağlanmalıdır.",
"enableDockerSocketLink": "Daha fazla bilgi", "enableDockerSocketLink": "Daha fazla bilgi",
"viewDockerContainers": "Docker Konteynerlerini Görüntüle", "viewDockerContainers": "Docker Konteynerlerini Görüntüle",
"containersIn": "{siteName} içindeki konteynerler", "containersIn": "{siteName} içindeki konteynerler",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "Güncelleme Mevcut", "newtUpdateAvailable": "Güncelleme Mevcut",
"newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"domainPickerEnterDomain": "Domain", "domainPickerEnterDomain": "Domain",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com veya sadece myapp", "domainPickerPlaceholder": "myapp.example.com",
"domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.",
"domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin", "domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin",
"domainPickerTabAll": "Tümü", "domainPickerTabAll": "Tümü",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "Protokol", "editInternalResourceDialogProtocol": "Protokol",
"editInternalResourceDialogSitePort": "Site Bağlantı Noktası", "editInternalResourceDialogSitePort": "Site Bağlantı Noktası",
"editInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma", "editInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma",
"editInternalResourceDialogDestinationIP": "Hedef IP",
"editInternalResourceDialogDestinationPort": "Hedef Bağlantı Noktası",
"editInternalResourceDialogCancel": "İptal", "editInternalResourceDialogCancel": "İptal",
"editInternalResourceDialogSaveResource": "Kaynağı Kaydet", "editInternalResourceDialogSaveResource": "Kaynağı Kaydet",
"editInternalResourceDialogSuccess": "Başarı", "editInternalResourceDialogSuccess": "Başarı",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "Site Bağlantı Noktası", "createInternalResourceDialogSitePort": "Site Bağlantı Noktası",
"createInternalResourceDialogSitePortDescription": "İstemci ile bağlanıldığında site üzerindeki kaynağa erişmek için bu bağlantı noktasını kullanın.", "createInternalResourceDialogSitePortDescription": "İstemci ile bağlanıldığında site üzerindeki kaynağa erişmek için bu bağlantı noktasını kullanın.",
"createInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma", "createInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma",
"createInternalResourceDialogDestinationIP": "Hedef IP", "createInternalResourceDialogDestinationIPDescription": "Kaynağın site ağındaki IP veya ana bilgisayar adresi.",
"createInternalResourceDialogDestinationIPDescription": "Site ağındaki kaynağın IP adresi.",
"createInternalResourceDialogDestinationPort": "Hedef Bağlantı Noktası",
"createInternalResourceDialogDestinationPortDescription": "Kaynağa erişilebilecek hedef IP üzerindeki bağlantı noktası.", "createInternalResourceDialogDestinationPortDescription": "Kaynağa erişilebilecek hedef IP üzerindeki bağlantı noktası.",
"createInternalResourceDialogCancel": "İptal", "createInternalResourceDialogCancel": "İptal",
"createInternalResourceDialogCreateResource": "Kaynak Oluştur", "createInternalResourceDialogCreateResource": "Kaynak Oluştur",
@ -1496,5 +1500,24 @@
"convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün" "convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün"
}, },
"internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi", "internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi",
"willbestoredas": "Şu şekilde depolanacak:" "willbestoredas": "Şu şekilde depolanacak:",
"idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı",
"customHeaders": "Özel Başlıklar",
"headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.",
"domainPickerProvidedDomain": "Sağlanan Alan Adı",
"domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı",
"domainPickerVerified": "Doğrulandı",
"domainPickerUnverified": "Doğrulanmadı",
"domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.",
"domainPickerError": "Hata",
"domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi",
"domainPickerErrorCheckAvailability": "Alan adı kullanılabilirliği kontrol edilemedi",
"domainPickerInvalidSubdomain": "Geçersiz alt alan adı",
"domainPickerInvalidSubdomainRemoved": "Girdi \"{sub}\" geçersiz olduğu için kaldırıldı.",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" {domain} için geçerli yapılamadı.",
"domainPickerSubdomainSanitized": "Alt alan adı temizlendi",
"domainPickerSubdomainCorrected": "\"{sub}\" \"{sanitized}\" olarak düzeltildi",
"resourceAddEntrypointsEditFile": "Dosyayı düzenle: config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "Dosyayı düzenle: docker-compose.yml"
} }

View file

@ -454,6 +454,8 @@
"accessRoleErrorAddDescription": "添加用户到角色时出错。", "accessRoleErrorAddDescription": "添加用户到角色时出错。",
"userSaved": "用户已保存", "userSaved": "用户已保存",
"userSavedDescription": "用户已更新。", "userSavedDescription": "用户已更新。",
"autoProvisioned": "自动设置",
"autoProvisionedDescription": "允许此用户由身份提供商自动管理",
"accessControlsDescription": "管理此用户在组织中可以访问和做什么", "accessControlsDescription": "管理此用户在组织中可以访问和做什么",
"accessControlsSubmit": "保存访问控制", "accessControlsSubmit": "保存访问控制",
"roles": "角色", "roles": "角色",
@ -511,6 +513,7 @@
"ipAddressErrorInvalidFormat": "无效的 IP 地址格式", "ipAddressErrorInvalidFormat": "无效的 IP 地址格式",
"ipAddressErrorInvalidOctet": "无效的 IP 地址", "ipAddressErrorInvalidOctet": "无效的 IP 地址",
"path": "路径", "path": "路径",
"matchPath": "匹配路径",
"ipAddressRange": "IP 范围", "ipAddressRange": "IP 范围",
"rulesErrorFetch": "获取规则失败", "rulesErrorFetch": "获取规则失败",
"rulesErrorFetchDescription": "获取规则时出错", "rulesErrorFetchDescription": "获取规则时出错",
@ -911,6 +914,8 @@
"idpConnectingToFinished": "已连接", "idpConnectingToFinished": "已连接",
"idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。", "idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。",
"idpErrorNotFound": "找不到 IdP", "idpErrorNotFound": "找不到 IdP",
"idpGoogleAlt": "Google",
"idpAzureAlt": "Azure",
"inviteInvalid": "无效邀请", "inviteInvalid": "无效邀请",
"inviteInvalidDescription": "邀请链接无效。", "inviteInvalidDescription": "邀请链接无效。",
"inviteErrorWrongUser": "邀请不是该用户的", "inviteErrorWrongUser": "邀请不是该用户的",
@ -982,6 +987,8 @@
"licenseTierProfessionalRequired": "需要专业版", "licenseTierProfessionalRequired": "需要专业版",
"licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。", "licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。",
"actionGetOrg": "获取组织", "actionGetOrg": "获取组织",
"updateOrgUser": "更新组织用户",
"createOrgUser": "创建组织用户",
"actionUpdateOrg": "更新组织", "actionUpdateOrg": "更新组织",
"actionUpdateUser": "更新用户", "actionUpdateUser": "更新用户",
"actionGetUser": "获取用户", "actionGetUser": "获取用户",
@ -991,6 +998,7 @@
"actionDeleteSite": "删除站点", "actionDeleteSite": "删除站点",
"actionGetSite": "获取站点", "actionGetSite": "获取站点",
"actionListSites": "站点列表", "actionListSites": "站点列表",
"actionApplyBlueprint": "应用蓝图",
"setupToken": "设置令牌", "setupToken": "设置令牌",
"setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenDescription": "从服务器控制台输入设置令牌。",
"setupTokenRequired": "需要设置令牌", "setupTokenRequired": "需要设置令牌",
@ -1133,8 +1141,8 @@
"sidebarLicense": "证书", "sidebarLicense": "证书",
"sidebarClients": "客户端(测试版)", "sidebarClients": "客户端(测试版)",
"sidebarDomains": "域", "sidebarDomains": "域",
"enableDockerSocket": "启用停靠套接字", "enableDockerSocket": "启用 Docker 蓝图",
"enableDockerSocketDescription": "启用 Docker Socket 发现以填充容器信息。必须向 Newt 提供 Socket 路径。", "enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。",
"enableDockerSocketLink": "了解更多", "enableDockerSocketLink": "了解更多",
"viewDockerContainers": "查看停靠容器", "viewDockerContainers": "查看停靠容器",
"containersIn": "{siteName} 中的容器", "containersIn": "{siteName} 中的容器",
@ -1234,7 +1242,7 @@
"newtUpdateAvailable": "更新可用", "newtUpdateAvailable": "更新可用",
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。",
"domainPickerEnterDomain": "域名", "domainPickerEnterDomain": "域名",
"domainPickerPlaceholder": "myapp.example.com、api.v1.mydomain.com 或仅 myapp", "domainPickerPlaceholder": "example.com",
"domainPickerDescription": "输入资源的完整域名以查看可用选项。", "domainPickerDescription": "输入资源的完整域名以查看可用选项。",
"domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。", "domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。",
"domainPickerTabAll": "所有", "domainPickerTabAll": "所有",
@ -1392,8 +1400,6 @@
"editInternalResourceDialogProtocol": "协议", "editInternalResourceDialogProtocol": "协议",
"editInternalResourceDialogSitePort": "站点端口", "editInternalResourceDialogSitePort": "站点端口",
"editInternalResourceDialogTargetConfiguration": "目标配置", "editInternalResourceDialogTargetConfiguration": "目标配置",
"editInternalResourceDialogDestinationIP": "目标IP",
"editInternalResourceDialogDestinationPort": "目标端口",
"editInternalResourceDialogCancel": "取消", "editInternalResourceDialogCancel": "取消",
"editInternalResourceDialogSaveResource": "保存资源", "editInternalResourceDialogSaveResource": "保存资源",
"editInternalResourceDialogSuccess": "成功", "editInternalResourceDialogSuccess": "成功",
@ -1424,9 +1430,7 @@
"createInternalResourceDialogSitePort": "站点端口", "createInternalResourceDialogSitePort": "站点端口",
"createInternalResourceDialogSitePortDescription": "使用此端口在连接到客户端时访问站点上的资源。", "createInternalResourceDialogSitePortDescription": "使用此端口在连接到客户端时访问站点上的资源。",
"createInternalResourceDialogTargetConfiguration": "目标配置", "createInternalResourceDialogTargetConfiguration": "目标配置",
"createInternalResourceDialogDestinationIP": "目标IP", "createInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。",
"createInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP地址。",
"createInternalResourceDialogDestinationPort": "目标端口",
"createInternalResourceDialogDestinationPortDescription": "资源在目标IP上可访问的端口。", "createInternalResourceDialogDestinationPortDescription": "资源在目标IP上可访问的端口。",
"createInternalResourceDialogCancel": "取消", "createInternalResourceDialogCancel": "取消",
"createInternalResourceDialogCreateResource": "创建资源", "createInternalResourceDialogCreateResource": "创建资源",
@ -1496,5 +1500,24 @@
"convertButton": "将此节点转换为管理自托管的" "convertButton": "将此节点转换为管理自托管的"
}, },
"internationaldomaindetected": "检测到国际域", "internationaldomaindetected": "检测到国际域",
"willbestoredas": "储存为:" "willbestoredas": "储存为:",
"idpGoogleDescription": "Google OAuth2/OIDC 提供商",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "自定义标题",
"headersValidationError": "头部必须是格式:头部名称:值。",
"domainPickerProvidedDomain": "提供的域",
"domainPickerFreeProvidedDomain": "免费提供的域",
"domainPickerVerified": "已验证",
"domainPickerUnverified": "未验证",
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
"domainPickerError": "错误",
"domainPickerErrorLoadDomains": "加载组织域名失败",
"domainPickerErrorCheckAvailability": "检查域可用性失败",
"domainPickerInvalidSubdomain": "无效的子域",
"domainPickerInvalidSubdomainRemoved": "输入 \"{sub}\" 已被移除,因为其无效。",
"domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 无法为 {domain} 变为有效。",
"domainPickerSubdomainSanitized": "子域已净化",
"domainPickerSubdomainCorrected": "\"{sub}\" 已被更正为 \"{sanitized}\"",
"resourceAddEntrypointsEditFile": "编辑文件config/traefik/traefik_config.yml",
"resourceExposePortsEditFile": "编辑文件docker-compose.yml"
} }

2264
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -21,13 +21,13 @@
"db:clear-migrations": "rm -rf server/migrations", "db:clear-migrations": "rm -rf server/migrations",
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
"start": "DB_TYPE=sqlite NODE_OPTIONS=--enable-source-maps NODE_ENV=development ENVIRONMENT=prod sh -c 'node dist/migrations.mjs && node dist/server.mjs'", "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
"email": "email dev --dir server/emails/templates --port 3005", "email": "email dev --dir server/emails/templates --port 3005",
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs" "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs"
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.4", "@asteasolutions/zod-to-openapi": "^7.3.4",
"@hookform/resolvers": "3.9.1", "@hookform/resolvers": "4.1.3",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
@ -49,15 +49,15 @@
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "0.5.0", "@react-email/components": "0.5.3",
"@react-email/render": "^1.2.0", "@react-email/render": "^1.2.0",
"@react-email/tailwind": "1.2.2", "@react-email/tailwind": "1.2.2",
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.2",
"@simplewebauthn/server": "^9.0.3", "@simplewebauthn/server": "^9.0.3",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"arctic": "^3.7.0", "arctic": "^3.7.0",
"axios": "1.11.0", "axios": "^1.12.2",
"better-sqlite3": "11.7.0", "better-sqlite3": "11.7.0",
"canvas-confetti": "1.9.3", "canvas-confetti": "1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -68,11 +68,11 @@
"cookies": "^0.9.1", "cookies": "^0.9.1",
"cors": "2.8.5", "cors": "2.8.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"drizzle-orm": "0.44.4", "drizzle-orm": "0.44.5",
"eslint": "9.33.0", "eslint": "9.35.0",
"eslint-config-next": "15.4.6", "eslint-config-next": "15.5.3",
"express": "5.1.0", "express": "5.1.0",
"express-rate-limit": "8.0.1", "express-rate-limit": "8.1.0",
"glob": "11.0.3", "glob": "11.0.3",
"helmet": "8.1.0", "helmet": "8.1.0",
"http-errors": "2.0.0", "http-errors": "2.0.0",
@ -81,30 +81,29 @@
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-react": "0.539.0", "lucide-react": "^0.544.0",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.4.6", "next": "15.5.3",
"next-intl": "^4.3.4", "next-intl": "^4.3.9",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "7.0.5", "nodemailer": "7.0.6",
"npm": "^11.5.2", "npm": "^11.6.0",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "^8.16.2", "pg": "^8.16.2",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.1.1", "react": "19.1.1",
"react-dom": "19.1.1", "react-dom": "19.1.1",
"react-easy-sort": "^1.6.0", "react-easy-sort": "^1.7.0",
"react-hook-form": "7.62.0", "react-hook-form": "7.62.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"rebuild": "0.1.2", "rebuild": "0.1.2",
"semver": "^7.7.2", "semver": "^7.7.2",
"source-map-support": "0.5.21",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.8",
"uuid": "^11.1.0", "uuid": "^13.0.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"winston": "3.17.0", "winston": "3.17.0",
"winston-daily-rotate-file": "5.0.0", "winston-daily-rotate-file": "5.0.0",
@ -114,9 +113,9 @@
"zod-validation-error": "3.5.2" "zod-validation-error": "3.5.2"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.49.0", "@dotenvx/dotenvx": "1.49.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@tailwindcss/postcss": "^4.1.12", "@tailwindcss/postcss": "^4.1.13",
"@types/better-sqlite3": "7.6.12", "@types/better-sqlite3": "7.6.12",
"@types/cookie-parser": "1.4.9", "@types/cookie-parser": "1.4.9",
"@types/cors": "2.8.19", "@types/cors": "2.8.19",
@ -126,25 +125,25 @@
"@types/jmespath": "^0.15.2", "@types/jmespath": "^0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24", "@types/node": "24.5.2",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "7.0.1",
"@types/pg": "8.15.5", "@types/pg": "8.15.5",
"@types/react": "19.1.12", "@types/react": "19.1.13",
"@types/react-dom": "19.1.9", "@types/react-dom": "19.1.9",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.1",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.18.1", "@types/ws": "8.18.1",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"drizzle-kit": "0.31.4", "drizzle-kit": "0.31.4",
"esbuild": "0.25.9", "esbuild": "0.25.10",
"esbuild-node-externals": "1.18.0", "esbuild-node-externals": "1.18.0",
"postcss": "^8", "postcss": "^8",
"react-email": "4.2.8", "react-email": "4.2.11",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.20.5", "tsx": "4.20.5",
"typescript": "^5", "typescript": "^5",
"typescript-eslint": "^8.40.0" "typescript-eslint": "^8.44.0"
}, },
"overrides": { "overrides": {
"emblor": { "emblor": {

BIN
public/idp/azure.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
public/idp/google.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -101,7 +101,9 @@ export enum ActionsEnum {
getApiKey = "getApiKey", getApiKey = "getApiKey",
createOrgDomain = "createOrgDomain", createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain", deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain" restartOrgDomain = "restartOrgDomain",
updateOrgUser = "updateOrgUser",
applyBlueprint = "applyBlueprint"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(

View file

@ -1,6 +1,6 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { db } from "@server/db"; import { db, resources, siteResources } from "@server/db";
import { exitNodes, sites } from "@server/db"; import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts"; import { __DIRNAME } from "@server/lib/consts";
@ -34,6 +34,44 @@ export async function getUniqueSiteName(orgId: string): Promise<string> {
} }
} }
export async function getUniqueResourceName(orgId: string): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const count = await db
.select({ niceId: resources.niceId, orgId: resources.orgId })
.from(resources)
.where(and(eq(resources.niceId, name), eq(resources.orgId, orgId)));
if (count.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueSiteResourceName(orgId: string): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const count = await db
.select({ niceId: siteResources.niceId, orgId: siteResources.orgId })
.from(siteResources)
.where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId)));
if (count.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueExitNodeEndpointName(): Promise<string> { export async function getUniqueExitNodeEndpointName(): Promise<string> {
let loops = 0; let loops = 0;
const count = await db const count = await db

View file

@ -50,3 +50,4 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];

View file

@ -71,6 +71,7 @@ export const resources = pgTable("resources", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
subdomain: varchar("subdomain"), subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"), fullDomain: varchar("fullDomain"),
@ -95,6 +96,7 @@ export const resources = pgTable("resources", {
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
headers: text("headers"), // comma-separated list of headers to add to the request
}); });
export const targets = pgTable("targets", { export const targets = pgTable("targets", {
@ -113,7 +115,9 @@ export const targets = pgTable("targets", {
method: varchar("method"), method: varchar("method"),
port: integer("port").notNull(), port: integer("port").notNull(),
internalPort: integer("internalPort"), internalPort: integer("internalPort"),
enabled: boolean("enabled").notNull().default(true) enabled: boolean("enabled").notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
}); });
export const exitNodes = pgTable("exitNodes", { export const exitNodes = pgTable("exitNodes", {
@ -127,7 +131,8 @@ export const exitNodes = pgTable("exitNodes", {
maxConnections: integer("maxConnections"), maxConnections: integer("maxConnections"),
online: boolean("online").notNull().default(false), online: boolean("online").notNull().default(false),
lastPing: integer("lastPing"), lastPing: integer("lastPing"),
type: text("type").default("gerbil") // gerbil, remoteExitNode type: text("type").default("gerbil"), // gerbil, remoteExitNode
region: varchar("region")
}); });
export const siteResources = pgTable("siteResources", { // this is for the clients export const siteResources = pgTable("siteResources", { // this is for the clients
@ -138,6 +143,7 @@ export const siteResources = pgTable("siteResources", { // this is for the clien
orgId: varchar("orgId") orgId: varchar("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
protocol: varchar("protocol").notNull(), protocol: varchar("protocol").notNull(),
proxyPort: integer("proxyPort").notNull(), proxyPort: integer("proxyPort").notNull(),
@ -212,7 +218,8 @@ export const userOrgs = pgTable("userOrgs", {
roleId: integer("roleId") roleId: integer("roleId")
.notNull() .notNull()
.references(() => roles.roleId), .references(() => roles.roleId),
isOwner: boolean("isOwner").notNull().default(false) isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false)
}); });
export const emailVerificationCodes = pgTable("emailVerificationCodes", { export const emailVerificationCodes = pgTable("emailVerificationCodes", {
@ -458,6 +465,7 @@ export const idpOidcConfig = pgTable("idpOidcConfig", {
idpId: integer("idpId") idpId: integer("idpId")
.notNull() .notNull()
.references(() => idp.idpId, { onDelete: "cascade" }), .references(() => idp.idpId, { onDelete: "cascade" }),
variant: varchar("variant").notNull().default("oidc"),
clientId: varchar("clientId").notNull(), clientId: varchar("clientId").notNull(),
clientSecret: varchar("clientSecret").notNull(), clientSecret: varchar("clientSecret").notNull(),
authUrl: varchar("authUrl").notNull(), authUrl: varchar("authUrl").notNull(),

View file

@ -18,6 +18,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export type Transaction = Parameters<Parameters<typeof db["transaction"]>[0]>[0];
function checkFileExists(filePath: string): boolean { function checkFileExists(filePath: string): boolean {
try { try {

View file

@ -77,6 +77,7 @@ export const resources = sqliteTable("resources", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
subdomain: text("subdomain"), subdomain: text("subdomain"),
fullDomain: text("fullDomain"), fullDomain: text("fullDomain"),
@ -107,6 +108,7 @@ export const resources = sqliteTable("resources", {
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
headers: text("headers"), // comma-separated list of headers to add to the request
}); });
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {
@ -125,7 +127,9 @@ export const targets = sqliteTable("targets", {
method: text("method"), method: text("method"),
port: integer("port").notNull(), port: integer("port").notNull(),
internalPort: integer("internalPort"), internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
}); });
export const exitNodes = sqliteTable("exitNodes", { export const exitNodes = sqliteTable("exitNodes", {
@ -139,23 +143,28 @@ export const exitNodes = sqliteTable("exitNodes", {
maxConnections: integer("maxConnections"), maxConnections: integer("maxConnections"),
online: integer("online", { mode: "boolean" }).notNull().default(false), online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"), lastPing: integer("lastPing"),
type: text("type").default("gerbil") // gerbil, remoteExitNode type: text("type").default("gerbil"), // gerbil, remoteExitNode
region: text("region")
}); });
export const siteResources = sqliteTable("siteResources", { // this is for the clients export const siteResources = sqliteTable("siteResources", {
siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), // this is for the clients
siteResourceId: integer("siteResourceId").primaryKey({
autoIncrement: true
}),
siteId: integer("siteId") siteId: integer("siteId")
.notNull() .notNull()
.references(() => sites.siteId, { onDelete: "cascade" }), .references(() => sites.siteId, { onDelete: "cascade" }),
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
protocol: text("protocol").notNull(), protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort").notNull(), proxyPort: integer("proxyPort").notNull(),
destinationPort: integer("destinationPort").notNull(), destinationPort: integer("destinationPort").notNull(),
destinationIp: text("destinationIp").notNull(), destinationIp: text("destinationIp").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
}); });
export const users = sqliteTable("user", { export const users = sqliteTable("user", {
@ -259,7 +268,9 @@ export const clientSites = sqliteTable("clientSites", {
siteId: integer("siteId") siteId: integer("siteId")
.notNull() .notNull()
.references(() => sites.siteId, { onDelete: "cascade" }), .references(() => sites.siteId, { onDelete: "cascade" }),
isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false), isRelayed: integer("isRelayed", { mode: "boolean" })
.notNull()
.default(false),
endpoint: text("endpoint") endpoint: text("endpoint")
}); });
@ -317,7 +328,10 @@ export const userOrgs = sqliteTable("userOrgs", {
roleId: integer("roleId") roleId: integer("roleId")
.notNull() .notNull()
.references(() => roles.roleId), .references(() => roles.roleId),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false) isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
autoProvisioned: integer("autoProvisioned", {
mode: "boolean"
}).default(false)
}); });
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
@ -594,6 +608,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
idpOauthConfigId: integer("idpOauthConfigId").primaryKey({ idpOauthConfigId: integer("idpOauthConfigId").primaryKey({
autoIncrement: true autoIncrement: true
}), }),
variant: text("variant").notNull().default("oidc"),
idpId: integer("idpId") idpId: integer("idpId")
.notNull() .notNull()
.references(() => idp.idpId, { onDelete: "cascade" }), .references(() => idp.idpId, { onDelete: "cascade" }),

View file

@ -1,6 +1,5 @@
#! /usr/bin/env node #! /usr/bin/env node
import "./extendZod.ts"; import "./extendZod.ts";
import 'source-map-support/register.js'
import { runSetupFunctions } from "./setup"; import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer"; import { createApiServer } from "./apiServer";

View file

@ -0,0 +1,170 @@
import { db, newts, Target } from "@server/db";
import { Config, ConfigSchema } from "./types";
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { resources, targets, sites } from "@server/db";
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
import { addTargets as addClientTargets } from "@server/routers/client/targets";
import {
ClientResourcesResults,
updateClientResources
} from "./clientResources";
export async function applyBlueprint(
orgId: string,
configData: unknown,
siteId?: number
): Promise<void> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
try {
let proxyResourcesResults: ProxyResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
proxyResourcesResults = await updateProxyResources(
orgId,
config,
trx,
siteId
);
clientResourcesResults = await updateClientResources(
orgId,
config,
trx,
siteId
);
});
logger.debug(
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information
for (const result of proxyResourcesResults) {
for (const target of result.targetsToUpdate) {
const [site] = await db
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, target.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (site) {
logger.debug(
`Updating target ${target.targetId} on site ${site.sites.siteId}`
);
await addProxyTargets(
site.newt.newtId,
[target],
result.proxyResource.protocol,
result.proxyResource.proxyPort
);
}
}
}
logger.debug(
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information
for (const result of clientResourcesResults) {
const [site] = await db
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, result.resource.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (site) {
logger.debug(
`Updating client resource ${result.resource.siteResourceId} on site ${site.sites.siteId}`
);
await addClientTargets(
site.newt.newtId,
result.resource.destinationIp,
result.resource.destinationPort,
result.resource.protocol,
result.resource.proxyPort
);
}
}
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
throw error;
}
}
// await updateDatabaseFromConfig("org_i21aifypnlyxur2", {
// resources: {
// "resource-nice-id": {
// name: "this is my resource",
// protocol: "http",
// "full-domain": "level1.test.example.com",
// "host-header": "example.com",
// "tls-server-name": "example.com",
// auth: {
// pincode: 123456,
// password: "sadfasdfadsf",
// "sso-enabled": true,
// "sso-roles": ["Member"],
// "sso-users": ["owen@fossorial.io"],
// "whitelist-users": ["owen@fossorial.io"]
// },
// targets: [
// {
// site: "glossy-plains-viscacha-rat",
// hostname: "localhost",
// method: "http",
// port: 8000,
// healthcheck: {
// port: 8000,
// hostname: "localhost"
// }
// },
// {
// site: "glossy-plains-viscacha-rat",
// hostname: "localhost",
// method: "http",
// port: 8001
// }
// ]
// },
// "resource-nice-id2": {
// name: "http server",
// protocol: "tcp",
// "proxy-port": 3000,
// targets: [
// {
// site: "glossy-plains-viscacha-rat",
// hostname: "localhost",
// port: 3000,
// }
// ]
// }
// }
// });

View file

@ -0,0 +1,53 @@
import { sendToClient } from "@server/routers/ws";
import { processContainerLabels } from "./parseDockerContainers";
import { applyBlueprint } from "./applyBlueprint";
import { db, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
export async function applyNewtDockerBlueprint(
siteId: number,
newtId: string,
containers: any
) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
logger.warn("Site not found in applyNewtDockerBlueprint");
return;
}
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
try {
const blueprint = processContainerLabels(containers);
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
// Update the blueprint in the database
await applyBlueprint(site.orgId, blueprint, site.siteId);
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
success: false,
message: `Failed to update database from config: ${error}`
}
});
return;
}
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
success: true,
message: "Config updated successfully"
}
});
}

View file

@ -0,0 +1,117 @@
import {
SiteResource,
siteResources,
Transaction,
} from "@server/db";
import { sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import {
Config,
} from "./types";
import logger from "@server/logger";
export type ClientResourcesResults = {
resource: SiteResource;
}[];
export async function updateClientResources(
orgId: string,
config: Config,
trx: Transaction,
siteId?: number
): Promise<ClientResourcesResults> {
const results: ClientResourcesResults = [];
for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"]
)) {
const [existingResource] = await trx
.select()
.from(siteResources)
.where(
and(
eq(siteResources.orgId, orgId),
eq(siteResources.niceId, resourceNiceId)
)
)
.limit(1);
const resourceSiteId = resourceData.site;
let site;
if (resourceSiteId) {
// Look up site by niceId
[site] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.orgId, orgId)
)
)
.limit(1);
} else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[site] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
} else {
throw new Error(`Target site is required`);
}
if (!site) {
throw new Error(
`Site not found: ${resourceSiteId} in org ${orgId}`
);
}
if (existingResource) {
// Update existing resource
const [updatedResource] = await trx
.update(siteResources)
.set({
name: resourceData.name || resourceNiceId,
siteId: site.siteId,
proxyPort: resourceData["proxy-port"]!,
destinationIp: resourceData.hostname,
destinationPort: resourceData["internal-port"],
protocol: resourceData.protocol
})
.where(
eq(
siteResources.siteResourceId,
existingResource.siteResourceId
)
)
.returning();
results.push({ resource: updatedResource });
} else {
// Create new resource
const [newResource] = await trx
.insert(siteResources)
.values({
orgId: orgId,
siteId: site.siteId,
niceId: resourceNiceId,
name: resourceData.name || resourceNiceId,
proxyPort: resourceData["proxy-port"]!,
destinationIp: resourceData.hostname,
destinationPort: resourceData["internal-port"],
protocol: resourceData.protocol
})
.returning();
logger.info(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
results.push({ resource: newResource });
}
}
return results;
}

View file

@ -0,0 +1,301 @@
import logger from "@server/logger";
import { setNestedProperty } from "./parseDotNotation";
export type DockerLabels = {
[key: string]: string;
};
export type ParsedObject = {
[key: string]: any;
};
type ContainerPort = {
privatePort: number;
publicPort: number;
type: string;
ip: string;
};
type Container = {
id: string;
name: string;
image: string;
state: string;
status: string;
ports: ContainerPort[] | null;
labels: DockerLabels;
created: number;
networks: { [key: string]: any };
hostname: string;
};
type Target = {
hostname?: string;
port?: number;
method?: string;
enabled?: boolean;
[key: string]: any;
};
type ResourceConfig = {
[key: string]: any;
targets?: (Target | null)[];
};
function getContainerPort(container: Container): number | null {
if (!container.ports || container.ports.length === 0) {
return null;
}
// Return the first port's privatePort
return container.ports[0].privatePort;
// return container.ports[0].publicPort;
}
export function processContainerLabels(containers: Container[]): {
"proxy-resources": { [key: string]: ResourceConfig };
"client-resources": { [key: string]: ResourceConfig };
} {
const result = {
"proxy-resources": {} as { [key: string]: ResourceConfig },
"client-resources": {} as { [key: string]: ResourceConfig }
};
// Process each container
containers.forEach((container) => {
if (container.state !== "running") {
return;
}
const proxyResourceLabels: DockerLabels = {};
const clientResourceLabels: DockerLabels = {};
// Filter and separate proxy-resources and client-resources labels
Object.entries(container.labels).forEach(([key, value]) => {
if (key.startsWith("pangolin.proxy-resources.")) {
// remove the pangolin.proxy- prefix to get "resources.xxx"
const strippedKey = key.replace("pangolin.proxy-", "");
proxyResourceLabels[strippedKey] = value;
} else if (key.startsWith("pangolin.client-resources.")) {
// remove the pangolin.client- prefix to get "resources.xxx"
const strippedKey = key.replace("pangolin.client-", "");
clientResourceLabels[strippedKey] = value;
}
});
// Process proxy resources
if (Object.keys(proxyResourceLabels).length > 0) {
processResourceLabels(proxyResourceLabels, container, result["proxy-resources"]);
}
// Process client resources
if (Object.keys(clientResourceLabels).length > 0) {
processResourceLabels(clientResourceLabels, container, result["client-resources"]);
}
});
return result;
}
function processResourceLabels(
resourceLabels: DockerLabels,
container: Container,
targetResult: { [key: string]: ResourceConfig }
) {
// Parse the labels using the existing parseDockerLabels logic
const tempResult: ParsedObject = {};
Object.entries(resourceLabels).forEach(([key, value]) => {
setNestedProperty(tempResult, key, value);
});
// Merge into target result
if (tempResult.resources) {
Object.entries(tempResult.resources).forEach(
([resourceKey, resourceConfig]: [string, any]) => {
// Initialize resource if it doesn't exist
if (!targetResult[resourceKey]) {
targetResult[resourceKey] = {};
}
// Merge all properties except targets
Object.entries(resourceConfig).forEach(
([propKey, propValue]) => {
if (propKey !== "targets") {
targetResult[resourceKey][propKey] = propValue;
}
}
);
// Handle targets specially
if (
resourceConfig.targets &&
Array.isArray(resourceConfig.targets)
) {
const resource = targetResult[resourceKey];
if (resource) {
if (!resource.targets) {
resource.targets = [];
}
resourceConfig.targets.forEach(
(target: any, targetIndex: number) => {
// check if the target is an empty object
if (
typeof target === "object" &&
Object.keys(target).length === 0
) {
logger.debug(
`Skipping null target at index ${targetIndex} for resource ${resourceKey}`
);
resource.targets!.push(null);
return;
}
// Ensure targets array is long enough
while (
resource.targets!.length <= targetIndex
) {
resource.targets!.push({});
}
// Set default hostname and port if not provided
const finalTarget = { ...target };
if (!finalTarget.hostname) {
finalTarget.hostname =
container.name ||
container.hostname;
}
if (!finalTarget.port) {
const containerPort =
getContainerPort(container);
if (containerPort !== null) {
finalTarget.port = containerPort;
}
}
// Merge with existing target data
resource.targets![targetIndex] = {
...resource.targets![targetIndex],
...finalTarget
};
}
);
}
}
}
);
}
}
// // Test example
// const testContainers: Container[] = [
// {
// id: "57e056cb0e3a",
// name: "nginx1",
// image: "nginxdemos/hello",
// state: "running",
// status: "Up 4 days",
// ports: [
// {
// privatePort: 80,
// publicPort: 8000,
// type: "tcp",
// ip: "0.0.0.0"
// }
// ],
// labels: {
// "resources.nginx.name": "nginx",
// "resources.nginx.full-domain": "nginx.example.com",
// "resources.nginx.protocol": "http",
// "resources.nginx.targets[0].enabled": "true"
// },
// created: 1756942725,
// networks: {
// owen_default: {
// networkId:
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
// }
// },
// hostname: "57e056cb0e3a"
// },
// {
// id: "58e056cb0e3b",
// name: "nginx2",
// image: "nginxdemos/hello",
// state: "running",
// status: "Up 4 days",
// ports: [
// {
// privatePort: 80,
// publicPort: 8001,
// type: "tcp",
// ip: "0.0.0.0"
// }
// ],
// labels: {
// "resources.nginx.name": "nginx",
// "resources.nginx.full-domain": "nginx.example.com",
// "resources.nginx.protocol": "http",
// "resources.nginx.targets[1].enabled": "true"
// },
// created: 1756942726,
// networks: {
// owen_default: {
// networkId:
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
// }
// },
// hostname: "58e056cb0e3b"
// },
// {
// id: "59e056cb0e3c",
// name: "api-server",
// image: "my-api:latest",
// state: "running",
// status: "Up 2 days",
// ports: [
// {
// privatePort: 3000,
// publicPort: 3000,
// type: "tcp",
// ip: "0.0.0.0"
// }
// ],
// labels: {
// "resources.api.name": "API Server",
// "resources.api.protocol": "http",
// "resources.api.targets[0].enabled": "true",
// "resources.api.targets[0].hostname": "custom-host",
// "resources.api.targets[0].port": "3001"
// },
// created: 1756942727,
// networks: {
// owen_default: {
// networkId:
// "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c"
// }
// },
// hostname: "59e056cb0e3c"
// },
// {
// id: "d0e29b08361c",
// name: "beautiful_wilson",
// image: "bolkedebruin/rdpgw:latest",
// state: "exited",
// status: "Exited (0) 4 hours ago",
// ports: null,
// labels: {},
// created: 1757359039,
// networks: {
// bridge: {
// networkId:
// "ea7f56dfc9cc476b8a3560b5b570d0fe8a6a2bc5e8343ab1ed37822086e89687"
// }
// },
// hostname: "d0e29b08361c"
// }
// ];
// // Test the function
// const result = processContainerLabels(testContainers);
// console.log("Processed result:");
// console.log(JSON.stringify(result, null, 2));

View file

@ -0,0 +1,109 @@
export function setNestedProperty(obj: any, path: string, value: string): void {
const keys = path.split(".");
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
// Handle array notation like "targets[0]"
const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, indexStr] = arrayMatch;
const index = parseInt(indexStr, 10);
// Initialize array if it doesn't exist
if (!current[arrayKey]) {
current[arrayKey] = [];
}
// Ensure array is long enough
while (current[arrayKey].length <= index) {
current[arrayKey].push({});
}
current = current[arrayKey][index];
} else {
// Regular object property
if (!current[key]) {
current[key] = {};
}
current = current[key];
}
}
// Set the final value
const finalKey = keys[keys.length - 1];
const arrayMatch = finalKey.match(/^(.+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, indexStr] = arrayMatch;
const index = parseInt(indexStr, 10);
if (!current[arrayKey]) {
current[arrayKey] = [];
}
// Ensure array is long enough
while (current[arrayKey].length <= index) {
current[arrayKey].push(null);
}
current[arrayKey][index] = convertValue(value);
} else {
current[finalKey] = convertValue(value);
}
}
// Helper function to convert string values to appropriate types
export function convertValue(value: string): any {
// Convert boolean strings
if (value === "true") return true;
if (value === "false") return false;
// Convert numeric strings
if (/^\d+$/.test(value)) {
const num = parseInt(value, 10);
return num;
}
if (/^\d*\.\d+$/.test(value)) {
const num = parseFloat(value);
return num;
}
// Return as string
return value;
}
// // Example usage:
// const dockerLabels: DockerLabels = {
// "resources.resource-nice-id.name": "this is my resource",
// "resources.resource-nice-id.protocol": "http",
// "resources.resource-nice-id.full-domain": "level1.test3.example.com",
// "resources.resource-nice-id.host-header": "example.com",
// "resources.resource-nice-id.tls-server-name": "example.com",
// "resources.resource-nice-id.auth.pincode": "123456",
// "resources.resource-nice-id.auth.password": "sadfasdfadsf",
// "resources.resource-nice-id.auth.sso-enabled": "true",
// "resources.resource-nice-id.auth.sso-roles[0]": "Member",
// "resources.resource-nice-id.auth.sso-users[0]": "owen@fossorial.io",
// "resources.resource-nice-id.auth.whitelist-users[0]": "owen@fossorial.io",
// "resources.resource-nice-id.targets[0].hostname": "localhost",
// "resources.resource-nice-id.targets[0].method": "http",
// "resources.resource-nice-id.targets[0].port": "8000",
// "resources.resource-nice-id.targets[0].healthcheck.port": "8000",
// "resources.resource-nice-id.targets[0].healthcheck.hostname": "localhost",
// "resources.resource-nice-id.targets[1].hostname": "localhost",
// "resources.resource-nice-id.targets[1].method": "http",
// "resources.resource-nice-id.targets[1].port": "8001",
// "resources.resource-nice-id2.name": "this is other resource",
// "resources.resource-nice-id2.protocol": "tcp",
// "resources.resource-nice-id2.proxy-port": "3000",
// "resources.resource-nice-id2.targets[0].hostname": "localhost",
// "resources.resource-nice-id2.targets[0].port": "3000"
// };
// // Parse the labels
// const parsed = parseDockerLabels(dockerLabels);
// console.log(JSON.stringify(parsed, null, 2));

View file

@ -0,0 +1,885 @@
import {
domains,
orgDomains,
Resource,
resourcePincode,
resourceRules,
resourceWhitelist,
roleResources,
roles,
Target,
Transaction,
userOrgs,
userResources,
users
} from "@server/db";
import { resources, targets, sites } from "@server/db";
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
import {
Config,
ConfigSchema,
isTargetsOnlyResource,
TargetData
} from "./types";
import logger from "@server/logger";
import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
export type ProxyResourcesResults = {
proxyResource: Resource;
targetsToUpdate: Target[];
}[];
export async function updateProxyResources(
orgId: string,
config: Config,
trx: Transaction,
siteId?: number
): Promise<ProxyResourcesResults> {
const results: ProxyResourcesResults = [];
for (const [resourceNiceId, resourceData] of Object.entries(
config["proxy-resources"]
)) {
const targetsToUpdate: Target[] = [];
let resource: Resource;
async function createTarget( // reusable function to create a target
resourceId: number,
targetData: TargetData
) {
const targetSiteId = targetData.site;
let site;
if (targetSiteId) {
// Look up site by niceId
[site] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, targetSiteId),
eq(sites.orgId, orgId)
)
)
.limit(1);
} else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[site] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
} else {
throw new Error(`Target site is required`);
}
if (!site) {
throw new Error(
`Site not found: ${targetSiteId} in org ${orgId}`
);
}
let internalPortToCreate;
if (!targetData["internal-port"]) {
const { internalPort, targetIps } = await pickPort(
site.siteId!,
trx
);
internalPortToCreate = internalPort;
} else {
internalPortToCreate = targetData["internal-port"];
}
// Create target
const [newTarget] = await trx
.insert(targets)
.values({
resourceId: resourceId,
siteId: site.siteId,
ip: targetData.hostname,
method: targetData.method,
port: targetData.port,
enabled: targetData.enabled,
internalPort: internalPortToCreate,
path: targetData.path,
pathMatchType: targetData["path-match"]
})
.returning();
targetsToUpdate.push(newTarget);
}
// Find existing resource by niceId and orgId
const [existingResource] = await trx
.select()
.from(resources)
.where(
and(
eq(resources.niceId, resourceNiceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
const http = resourceData.protocol == "http";
const protocol =
resourceData.protocol == "http" ? "tcp" : resourceData.protocol;
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceData.enabled;
const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null
? true
: resourceData.ssl;
let headers = "";
for (const header of resourceData.headers || []) {
headers += `${header.name}: ${header.value},`;
}
// if there are headers, remove the trailing comma
if (headers.endsWith(",")) {
headers = headers.slice(0, -1);
}
if (existingResource) {
let domain;
if (http) {
domain = await getDomain(
existingResource.resourceId,
resourceData["full-domain"]!,
orgId,
trx
);
}
// check if the only key in the resource is targets, if so, skip the update
if (isTargetsOnlyResource(resourceData)) {
logger.debug(
`Skipping update for resource ${existingResource.resourceId} as only targets are provided`
);
resource = existingResource;
} else {
// Update existing resource
[resource] = await trx
.update(resources)
.set({
name: resourceData.name || "Unnamed Resource",
protocol: protocol || "http",
http: http,
proxyPort: http ? null : resourceData["proxy-port"],
fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null,
enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
ssl: resourceSsl,
setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null,
emailWhitelistEnabled: resourceData.auth?.[
"whitelist-users"
]
? resourceData.auth["whitelist-users"].length > 0
: false,
headers: headers || null,
applyRules:
resourceData.rules && resourceData.rules.length > 0
})
.where(
eq(resources.resourceId, existingResource.resourceId)
)
.returning();
await trx
.delete(resourcePassword)
.where(
eq(
resourcePassword.resourceId,
existingResource.resourceId
)
);
if (resourceData.auth?.password) {
const passwordHash = await hashPassword(
resourceData.auth.password
);
await trx.insert(resourcePassword).values({
resourceId: existingResource.resourceId,
passwordHash
});
}
await trx
.delete(resourcePincode)
.where(
eq(
resourcePincode.resourceId,
existingResource.resourceId
)
);
if (resourceData.auth?.pincode) {
const pincodeHash = await hashPassword(
resourceData.auth.pincode.toString()
);
await trx.insert(resourcePincode).values({
resourceId: existingResource.resourceId,
pincodeHash,
digitLength: 6
});
}
if (resourceData.auth?.["sso-roles"]) {
const ssoRoles = resourceData.auth?.["sso-roles"];
await syncRoleResources(
existingResource.resourceId,
ssoRoles,
orgId,
trx
);
}
if (resourceData.auth?.["sso-users"]) {
const ssoUsers = resourceData.auth?.["sso-users"];
await syncUserResources(
existingResource.resourceId,
ssoUsers,
orgId,
trx
);
}
if (resourceData.auth?.["whitelist-users"]) {
const whitelistUsers =
resourceData.auth?.["whitelist-users"];
await syncWhitelistUsers(
existingResource.resourceId,
whitelistUsers,
orgId,
trx
);
}
}
const existingResourceTargets = await trx
.select()
.from(targets)
.where(eq(targets.resourceId, existingResource.resourceId))
.orderBy(asc(targets.targetId));
// Create new targets
for (const [index, targetData] of resourceData.targets.entries()) {
if (
!targetData ||
(typeof targetData === "object" &&
Object.keys(targetData).length === 0)
) {
// If targetData is null or an empty object, we can skip it
continue;
}
const existingTarget = existingResourceTargets[index];
if (existingTarget) {
const targetSiteId = targetData.site;
let site;
if (targetSiteId) {
// Look up site by niceId
[site] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.niceId, targetSiteId),
eq(sites.orgId, orgId)
)
)
.limit(1);
} else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[site] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(
and(
eq(sites.siteId, siteId),
eq(sites.orgId, orgId)
)
)
.limit(1);
} else {
throw new Error(`Target site is required`);
}
if (!site) {
throw new Error(
`Site not found: ${targetSiteId} in org ${orgId}`
);
}
// update this target
const [updatedTarget] = await trx
.update(targets)
.set({
siteId: site.siteId,
ip: targetData.hostname,
method: http ? targetData.method : null,
port: targetData.port,
enabled: targetData.enabled,
path: targetData.path,
pathMatchType: targetData["path-match"]
})
.where(eq(targets.targetId, existingTarget.targetId))
.returning();
if (checkIfTargetChanged(existingTarget, updatedTarget)) {
let internalPortToUpdate;
if (!targetData["internal-port"]) {
const { internalPort, targetIps } = await pickPort(
site.siteId!,
trx
);
internalPortToUpdate = internalPort;
} else {
internalPortToUpdate = targetData["internal-port"];
}
const [finalUpdatedTarget] = await trx // this double is so we can check the whole target before and after
.update(targets)
.set({
internalPort: internalPortToUpdate
})
.where(
eq(targets.targetId, existingTarget.targetId)
)
.returning();
targetsToUpdate.push(finalUpdatedTarget);
}
} else {
await createTarget(existingResource.resourceId, targetData);
}
}
if (existingResourceTargets.length > resourceData.targets.length) {
const targetsToDelete = existingResourceTargets.slice(
resourceData.targets.length
);
logger.debug(
`Targets to delete: ${JSON.stringify(targetsToDelete)}`
);
for (const target of targetsToDelete) {
if (!target) {
continue;
}
if (siteId && target.siteId !== siteId) {
logger.debug(
`Skipping target ${target.targetId} for deletion. Site ID does not match filter.`
);
continue; // only delete targets for the specified siteId
}
logger.debug(`Deleting target ${target.targetId}`);
await trx
.delete(targets)
.where(eq(targets.targetId, target.targetId));
}
}
const existingRules = await trx
.select()
.from(resourceRules)
.where(
eq(resourceRules.resourceId, existingResource.resourceId)
)
.orderBy(resourceRules.priority);
// Sync rules
for (const [index, rule] of resourceData.rules?.entries() || []) {
const existingRule = existingRules[index];
if (existingRule) {
if (
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !== rule.value
) {
validateRule(rule);
await trx
.update(resourceRules)
.set({
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: rule.value
})
.where(
eq(resourceRules.ruleId, existingRule.ruleId)
);
}
} else {
validateRule(rule);
await trx.insert(resourceRules).values({
resourceId: existingResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: rule.value,
priority: index + 1 // start priorities at 1
});
}
}
if (existingRules.length > (resourceData.rules?.length || 0)) {
const rulesToDelete = existingRules.slice(
resourceData.rules?.length || 0
);
for (const rule of rulesToDelete) {
await trx
.delete(resourceRules)
.where(eq(resourceRules.ruleId, rule.ruleId));
}
}
logger.debug(`Updated resource ${existingResource.resourceId}`);
} else {
// create a brand new resource
let domain;
if (http) {
domain = await getDomain(
undefined,
resourceData["full-domain"]!,
orgId,
trx
);
}
// Create new resource
const [newResource] = await trx
.insert(resources)
.values({
orgId,
niceId: resourceNiceId,
name: resourceData.name || "Unnamed Resource",
protocol: resourceData.protocol || "http",
http: http,
proxyPort: http ? null : resourceData["proxy-port"],
fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null,
enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null,
ssl: resourceSsl,
headers: headers || null,
applyRules:
resourceData.rules && resourceData.rules.length > 0
})
.returning();
if (resourceData.auth?.password) {
const passwordHash = await hashPassword(
resourceData.auth.password
);
await trx.insert(resourcePassword).values({
resourceId: newResource.resourceId,
passwordHash
});
}
if (resourceData.auth?.pincode) {
const pincodeHash = await hashPassword(
resourceData.auth.pincode.toString()
);
await trx.insert(resourcePincode).values({
resourceId: newResource.resourceId,
pincodeHash,
digitLength: 6
});
}
resource = newResource;
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error(`Admin role not found`);
}
await trx.insert(roleResources).values({
roleId: adminRole.roleId,
resourceId: newResource.resourceId
});
if (resourceData.auth?.["sso-roles"]) {
const ssoRoles = resourceData.auth?.["sso-roles"];
await syncRoleResources(
newResource.resourceId,
ssoRoles,
orgId,
trx
);
}
if (resourceData.auth?.["sso-users"]) {
const ssoUsers = resourceData.auth?.["sso-users"];
await syncUserResources(
newResource.resourceId,
ssoUsers,
orgId,
trx
);
}
if (resourceData.auth?.["whitelist-users"]) {
const whitelistUsers = resourceData.auth?.["whitelist-users"];
await syncWhitelistUsers(
newResource.resourceId,
whitelistUsers,
orgId,
trx
);
}
// Create new targets
for (const targetData of resourceData.targets) {
if (!targetData) {
// If targetData is null or an empty object, we can skip it
continue;
}
await createTarget(newResource.resourceId, targetData);
}
for (const [index, rule] of resourceData.rules?.entries() || []) {
validateRule(rule);
await trx.insert(resourceRules).values({
resourceId: newResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
value: rule.value,
priority: index + 1 // start priorities at 1
});
}
logger.debug(`Created resource ${newResource.resourceId}`);
}
results.push({
proxyResource: resource,
targetsToUpdate
});
}
return results;
}
function getRuleAction(input: string) {
let action = "DROP";
if (input == "allow") {
action = "ACCEPT";
} else if (input == "deny") {
action = "DROP";
} else if (input == "pass") {
action = "PASS";
}
return action;
}
function validateRule(rule: any) {
if (rule.match === "cidr") {
if (!isValidCIDR(rule.value)) {
throw new Error(`Invalid CIDR provided: ${rule.value}`);
}
} else if (rule.match === "ip") {
if (!isValidIP(rule.value)) {
throw new Error(`Invalid IP provided: ${rule.value}`);
}
} else if (rule.match === "path") {
if (!isValidUrlGlobPattern(rule.value)) {
throw new Error(`Invalid URL glob pattern: ${rule.value}`);
}
}
}
async function syncRoleResources(
resourceId: number,
ssoRoles: string[],
orgId: string,
trx: Transaction
) {
const existingRoleResources = await trx
.select()
.from(roleResources)
.where(eq(roleResources.resourceId, resourceId));
for (const roleName of ssoRoles) {
if (roleName === "Admin") {
continue; // never add admin access
}
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
throw new Error(`Role not found: ${roleName} in org ${orgId}`);
}
const existingRoleResource = existingRoleResources.find(
(rr) => rr.roleId === role.roleId
);
if (!existingRoleResource) {
await trx.insert(roleResources).values({
roleId: role.roleId,
resourceId: resourceId
});
}
}
for (const existingRoleResource of existingRoleResources) {
const [role] = await trx
.select()
.from(roles)
.where(eq(roles.roleId, existingRoleResource.roleId))
.limit(1);
if (role.isAdmin) {
continue; // never remove admin access
}
if (role && !ssoRoles.includes(role.name)) {
await trx
.delete(roleResources)
.where(
and(
eq(roleResources.roleId, existingRoleResource.roleId),
eq(roleResources.resourceId, resourceId)
)
);
}
}
}
async function syncUserResources(
resourceId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
const existingUserResources = await trx
.select()
.from(userResources)
.where(eq(userResources.resourceId, resourceId));
for (const email of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!user) {
throw new Error(`User not found: ${email} in org ${orgId}`);
}
const existingUserResource = existingUserResources.find(
(rr) => rr.userId === user.user.userId
);
if (!existingUserResource) {
await trx.insert(userResources).values({
userId: user.user.userId,
resourceId: resourceId
});
}
}
for (const existingUserResource of existingUserResources) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
eq(users.userId, existingUserResource.userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (user && user.user.email && !ssoUsers.includes(user.user.email)) {
await trx
.delete(userResources)
.where(
and(
eq(userResources.userId, existingUserResource.userId),
eq(userResources.resourceId, resourceId)
)
);
}
}
}
async function syncWhitelistUsers(
resourceId: number,
whitelistUsers: string[],
orgId: string,
trx: Transaction
) {
const existingWhitelist = await trx
.select()
.from(resourceWhitelist)
.where(eq(resourceWhitelist.resourceId, resourceId));
for (const email of whitelistUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!user) {
throw new Error(`User not found: ${email} in org ${orgId}`);
}
const existingWhitelistEntry = existingWhitelist.find(
(w) => w.email === email
);
if (!existingWhitelistEntry) {
await trx.insert(resourceWhitelist).values({
email,
resourceId: resourceId
});
}
}
for (const existingWhitelistEntry of existingWhitelist) {
if (!whitelistUsers.includes(existingWhitelistEntry.email)) {
await trx
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(
resourceWhitelist.email,
existingWhitelistEntry.email
)
)
);
}
}
}
function checkIfTargetChanged(
existing: Target | undefined,
incoming: Target | undefined
): boolean {
if (!existing && incoming) return true;
if (existing && !incoming) return true;
if (!existing || !incoming) return false;
if (existing.ip !== incoming.ip) return true;
if (existing.port !== incoming.port) return true;
if (existing.siteId !== incoming.siteId) return true;
return false;
}
async function getDomain(
resourceId: number | undefined,
fullDomain: string,
orgId: string,
trx: Transaction
) {
const [fullDomainExists] = await trx
.select({ resourceId: resources.resourceId })
.from(resources)
.where(
and(
eq(resources.fullDomain, fullDomain),
eq(resources.orgId, orgId),
resourceId
? ne(resources.resourceId, resourceId)
: isNotNull(resources.resourceId)
)
)
.limit(1);
if (fullDomainExists) {
throw new Error(
`Resource already exists: ${fullDomain} in org ${orgId}`
);
}
const domain = await getDomainId(orgId, fullDomain, trx);
if (!domain) {
throw new Error(
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
);
}
return domain;
}
async function getDomainId(
orgId: string,
fullDomain: string,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string } | null> {
const possibleDomains = await trx
.select()
.from(domains)
.innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId))
.where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true)))
.execute();
if (possibleDomains.length === 0) {
return null;
}
const validDomains = possibleDomains.filter((domain) => {
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
return (
fullDomain === domain.domains.baseDomain ||
fullDomain.endsWith(`.${domain.domains.baseDomain}`)
);
} else if (domain.domains.type == "cname") {
return fullDomain === domain.domains.baseDomain;
}
});
if (validDomains.length === 0) {
return null;
}
const domainSelection = validDomains[0].domains;
const baseDomain = domainSelection.baseDomain;
// remove the base domain of the domain
let subdomain = null;
if (domainSelection.type == "ns") {
if (fullDomain != baseDomain) {
subdomain = fullDomain.replace(`.${baseDomain}`, "");
}
}
// Return the first valid domain
return {
subdomain: subdomain,
domainId: domainSelection.domainId
};
}

View file

@ -0,0 +1,366 @@
import { z } from "zod";
export const SiteSchema = z.object({
name: z.string().min(1).max(100),
"docker-socket-enabled": z.boolean().optional().default(true)
});
// Schema for individual target within a resource
export const TargetSchema = z.object({
site: z.string().optional(),
method: z.enum(["http", "https", "h2c"]).optional(),
hostname: z.string(),
port: z.number().int().min(1).max(65535),
enabled: z.boolean().optional().default(true),
"internal-port": z.number().int().min(1).max(65535).optional(),
path: z.string().optional(),
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable()
});
export type TargetData = z.infer<typeof TargetSchema>;
export const AuthSchema = z.object({
// pincode has to have 6 digits
pincode: z.number().min(100000).max(999999).optional(),
password: z.string().min(1).optional(),
"sso-enabled": z.boolean().optional().default(false),
"sso-roles": z
.array(z.string())
.optional()
.default([])
.refine((roles) => !roles.includes("Admin"), {
message: "Admin role cannot be included in sso-roles"
}),
"sso-users": z.array(z.string().email()).optional().default([]),
"whitelist-users": z.array(z.string().email()).optional().default([]),
});
export const RuleSchema = z.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country"]),
value: z.string()
});
export const HeaderSchema = z.object({
name: z.string().min(1),
value: z.string().min(1)
});
// Schema for individual resource
export const ResourceSchema = z
.object({
name: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(),
ssl: z.boolean().optional(),
"full-domain": z.string().optional(),
"proxy-port": z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional(),
targets: z.array(TargetSchema.nullable()).optional().default([]),
auth: AuthSchema.optional(),
"host-header": z.string().optional(),
"tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional()
})
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// Otherwise, require name and protocol for full resource definition
return (
resource.name !== undefined && resource.protocol !== undefined
);
},
{
message:
"Resource must either be targets-only (only 'targets' field) or have both 'name' and 'protocol' fields at a minimum",
path: ["name", "protocol"]
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol is http, all targets must have method field
if (resource.protocol === "http") {
return resource.targets.every(
(target) => target == null || target.method !== undefined
);
}
// If protocol is tcp or udp, no target should have method field
if (resource.protocol === "tcp" || resource.protocol === "udp") {
return resource.targets.every(
(target) => target == null || target.method === undefined
);
}
return true;
},
(resource) => {
if (resource.protocol === "http") {
return {
message:
"When protocol is 'http', all targets must have a 'method' field",
path: ["targets"]
};
}
return {
message:
"When protocol is 'tcp' or 'udp', targets must not have a 'method' field",
path: ["targets"]
};
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol is http, it must have a full-domain
if (resource.protocol === "http") {
return (
resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0
);
}
return true;
},
{
message:
"When protocol is 'http', a 'full-domain' must be provided",
path: ["full-domain"]
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol is tcp or udp, it must have both proxy-port
if (resource.protocol === "tcp" || resource.protocol === "udp") {
return resource["proxy-port"] !== undefined;
}
return true;
},
{
message:
"When protocol is 'tcp' or 'udp', 'proxy-port' must be provided",
path: ["proxy-port", "exit-node"]
}
)
.refine(
(resource) => {
// Skip validation for targets-only resources
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol is tcp or udp, it must not have auth
if (resource.protocol === "tcp" || resource.protocol === "udp") {
return resource.auth === undefined;
}
return true;
},
{
message:
"When protocol is 'tcp' or 'udp', 'auth' must not be provided",
path: ["auth"]
}
);
export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets;
}
export const ClientResourceSchema = z.object({
name: z.string().min(2).max(100),
site: z.string().min(2).max(100).optional(),
protocol: z.enum(["tcp", "udp"]),
"proxy-port": z.number().min(1).max(65535),
"hostname": z.string().min(1).max(255),
"internal-port": z.number().min(1).max(65535),
enabled: z.boolean().optional().default(true)
});
// Schema for the entire configuration object
export const ConfigSchema = z
.object({
"proxy-resources": z.record(z.string(), ResourceSchema).optional().default({}),
"client-resources": z.record(z.string(), ClientResourceSchema).optional().default({}),
sites: z.record(z.string(), SiteSchema).optional().default({})
})
.refine(
// Enforce the full-domain uniqueness across resources in the same stack
(config) => {
// Extract all full-domain values with their resource keys
const fullDomainMap = new Map<string, string[]>();
Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => {
const fullDomain = resource["full-domain"];
if (fullDomain) {
// Only process if full-domain is defined
if (!fullDomainMap.has(fullDomain)) {
fullDomainMap.set(fullDomain, []);
}
fullDomainMap.get(fullDomain)!.push(resourceKey);
}
}
);
// Find duplicates
const duplicates = Array.from(fullDomainMap.entries()).filter(
([_, resourceKeys]) => resourceKeys.length > 1
);
return duplicates.length === 0;
},
(config) => {
// Extract duplicates for error message
const fullDomainMap = new Map<string, string[]>();
Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => {
const fullDomain = resource["full-domain"];
if (fullDomain) {
// Only process if full-domain is defined
if (!fullDomainMap.has(fullDomain)) {
fullDomainMap.set(fullDomain, []);
}
fullDomainMap.get(fullDomain)!.push(resourceKey);
}
}
);
const duplicates = Array.from(fullDomainMap.entries())
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([fullDomain, resourceKeys]) =>
`'${fullDomain}' used by resources: ${resourceKeys.join(", ")}`
)
.join("; ");
return {
message: `Duplicate 'full-domain' values found: ${duplicates}`,
path: ["resources"]
};
}
)
.refine(
// Enforce proxy-port uniqueness within proxy-resources
(config) => {
const proxyPortMap = new Map<number, string[]>();
Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => {
const proxyPort = resource["proxy-port"];
if (proxyPort !== undefined) {
if (!proxyPortMap.has(proxyPort)) {
proxyPortMap.set(proxyPort, []);
}
proxyPortMap.get(proxyPort)!.push(resourceKey);
}
}
);
// Find duplicates
const duplicates = Array.from(proxyPortMap.entries()).filter(
([_, resourceKeys]) => resourceKeys.length > 1
);
return duplicates.length === 0;
},
(config) => {
// Extract duplicates for error message
const proxyPortMap = new Map<number, string[]>();
Object.entries(config["proxy-resources"]).forEach(
([resourceKey, resource]) => {
const proxyPort = resource["proxy-port"];
if (proxyPort !== undefined) {
if (!proxyPortMap.has(proxyPort)) {
proxyPortMap.set(proxyPort, []);
}
proxyPortMap.get(proxyPort)!.push(resourceKey);
}
}
);
const duplicates = Array.from(proxyPortMap.entries())
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([proxyPort, resourceKeys]) =>
`port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}`
)
.join("; ");
return {
message: `Duplicate 'proxy-port' values found in proxy-resources: ${duplicates}`,
path: ["proxy-resources"]
};
}
)
.refine(
// Enforce proxy-port uniqueness within client-resources
(config) => {
const proxyPortMap = new Map<number, string[]>();
Object.entries(config["client-resources"]).forEach(
([resourceKey, resource]) => {
const proxyPort = resource["proxy-port"];
if (proxyPort !== undefined) {
if (!proxyPortMap.has(proxyPort)) {
proxyPortMap.set(proxyPort, []);
}
proxyPortMap.get(proxyPort)!.push(resourceKey);
}
}
);
// Find duplicates
const duplicates = Array.from(proxyPortMap.entries()).filter(
([_, resourceKeys]) => resourceKeys.length > 1
);
return duplicates.length === 0;
},
(config) => {
// Extract duplicates for error message
const proxyPortMap = new Map<number, string[]>();
Object.entries(config["client-resources"]).forEach(
([resourceKey, resource]) => {
const proxyPort = resource["proxy-port"];
if (proxyPort !== undefined) {
if (!proxyPortMap.has(proxyPort)) {
proxyPortMap.set(proxyPort, []);
}
proxyPortMap.get(proxyPort)!.push(resourceKey);
}
}
);
const duplicates = Array.from(proxyPortMap.entries())
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([proxyPort, resourceKeys]) =>
`port ${proxyPort} used by client-resources: ${resourceKeys.join(", ")}`
)
.join("; ");
return {
message: `Duplicate 'proxy-port' values found in client-resources: ${duplicates}`,
path: ["client-resources"]
};
}
);
// Type inference from the schema
export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof ResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;

View file

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

112
server/lib/domainUtils.ts Normal file
View file

@ -0,0 +1,112 @@
import { db } from "@server/db";
import { domains, orgDomains } from "@server/db";
import { eq, and } from "drizzle-orm";
import { subdomainSchema } from "@server/lib/schemas";
import { fromError } from "zod-validation-error";
export type DomainValidationResult = {
success: true;
fullDomain: string;
subdomain: string | null;
} | {
success: false;
error: string;
};
/**
* Validates a domain and constructs the full domain based on domain type and subdomain.
*
* @param domainId - The ID of the domain to validate
* @param orgId - The organization ID to check domain access
* @param subdomain - Optional subdomain to append (for ns and wildcard domains)
* @returns DomainValidationResult with success status and either fullDomain/subdomain or error message
*/
export async function validateAndConstructDomain(
domainId: string,
orgId: string,
subdomain?: string | null
): Promise<DomainValidationResult> {
try {
// Query domain with organization access check
const [domainRes] = await db
.select()
.from(domains)
.where(eq(domains.domainId, domainId))
.leftJoin(
orgDomains,
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
);
// Check if domain exists
if (!domainRes || !domainRes.domains) {
return {
success: false,
error: `Domain with ID ${domainId} not found`
};
}
// Check if organization has access to domain
if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) {
return {
success: false,
error: `Organization does not have access to domain with ID ${domainId}`
};
}
// Check if domain is verified
if (!domainRes.domains.verified) {
return {
success: false,
error: `Domain with ID ${domainId} is not verified`
};
}
// Construct full domain based on domain type
let fullDomain = "";
let finalSubdomain = subdomain;
if (domainRes.domains.type === "ns") {
if (subdomain) {
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
}
} else if (domainRes.domains.type === "cname") {
fullDomain = domainRes.domains.baseDomain;
finalSubdomain = null; // CNAME domains don't use subdomains
} else if (domainRes.domains.type === "wildcard") {
if (subdomain !== undefined && subdomain !== null) {
// Validate subdomain format for wildcard domains
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return {
success: false,
error: fromError(parsedSubdomain.error).toString()
};
}
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
}
}
// If the full domain equals the base domain, set subdomain to null
if (fullDomain === domainRes.domains.baseDomain) {
finalSubdomain = null;
}
// Convert to lowercase
fullDomain = fullDomain.toLowerCase();
return {
success: true,
fullDomain,
subdomain: finalSubdomain ?? null
};
} catch (error) {
return {
success: false,
error: `An error occurred while validating domain: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}

View file

@ -3,7 +3,7 @@ import logger from "@server/logger";
import { ExitNode } from "@server/db"; import { ExitNode } from "@server/db";
interface ExitNodeRequest { interface ExitNodeRequest {
remoteType: string; remoteType?: string;
localPath: string; localPath: string;
method?: "POST" | "DELETE" | "GET" | "PUT"; method?: "POST" | "DELETE" | "GET" | "PUT";
data?: any; data?: any;

View file

@ -30,7 +30,8 @@ export async function listExitNodes(orgId: string, filterOnline = false) {
maxConnections: exitNodes.maxConnections, maxConnections: exitNodes.maxConnections,
online: exitNodes.online, online: exitNodes.online,
lastPing: exitNodes.lastPing, lastPing: exitNodes.lastPing,
type: exitNodes.type type: exitNodes.type,
region: exitNodes.region
}) })
.from(exitNodes); .from(exitNodes);

View file

@ -15,6 +15,7 @@ import {
getValidCertificatesForDomains, getValidCertificatesForDomains,
getValidCertificatesForDomainsHybrid getValidCertificatesForDomainsHybrid
} from "./remoteCertificates"; } from "./remoteCertificates";
import { sendToExitNode } from "./exitNodeComms";
export class TraefikConfigManager { export class TraefikConfigManager {
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
@ -403,27 +404,11 @@ export class TraefikConfigManager {
[exitNode] = await db.select().from(exitNodes).limit(1); [exitNode] = await db.select().from(exitNodes).limit(1);
} }
if (exitNode) { if (exitNode) {
try { await sendToExitNode(exitNode, {
await axios.post( localPath: "/update-local-snis",
`${exitNode.reachableAt}/update-local-snis`, method: "POST",
{ fullDomains: Array.from(domains) }, data: { fullDomains: Array.from(domains) }
{ headers: { "Content-Type": "application/json" } } });
);
} catch (error) {
// pull data out of the axios error to log
if (axios.isAxiosError(error)) {
logger.error("Error updating local SNI:", {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method
});
} else {
logger.error("Error updating local SNI:", error);
}
}
} else { } else {
logger.error( logger.error(
"No exit node found. Has gerbil registered yet?" "No exit node found. Has gerbil registered yet?"

View file

@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean {
return true; return true;
} }
export function validateHeaders(headers: string): boolean {
// Validate comma-separated headers in format "Header-Name: value"
const headerPairs = headers.split(",").map((pair) => pair.trim());
return headerPairs.every((pair) => {
// Check if the pair contains exactly one colon
const colonCount = (pair.match(/:/g) || []).length;
if (colonCount !== 1) {
return false;
}
const colonIndex = pair.indexOf(":");
if (colonIndex === 0 || colonIndex === pair.length - 1) {
return false;
}
const headerName = pair.substring(0, colonIndex).trim();
const headerValue = pair.substring(colonIndex + 1).trim();
// Header name should not be empty and should contain valid characters
// Header names are case-insensitive and can contain alphanumeric, hyphens
const headerNameRegex = /^[a-zA-Z0-9\-_]+$/;
if (!headerName || !headerNameRegex.test(headerName)) {
return false;
}
// Header value should not be empty and should not contain colons
if (!headerValue || headerValue.includes(":")) {
return false;
}
return true;
});
}
const validTlds = [ const validTlds = [
"AAA", "AAA",
"AARP", "AARP",

View file

@ -19,6 +19,11 @@ export async function verifyApiKeySetResourceUsers(
); );
} }
if (apiKey.isRoot) {
// Root keys can access any key in any org
return next();
}
if (!req.apiKeyOrg) { if (!req.apiKeyOrg) {
return next( return next(
createHttpError( createHttpError(
@ -32,11 +37,6 @@ export async function verifyApiKeySetResourceUsers(
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs"));
} }
if (apiKey.isRoot) {
// Root keys can access any key in any org
return next();
}
if (userIds.length === 0) { if (userIds.length === 0) {
return next(); return next();
} }

View file

@ -343,6 +343,12 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getResource), verifyUserHasAction(ActionsEnum.getResource),
resource.getResource resource.getResource
); );
authenticated.get(
"/org/:orgId/resource/:niceId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getResource),
resource.getResource
);
authenticated.post( authenticated.post(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
@ -582,6 +588,14 @@ authenticated.put(
user.createOrgUser user.createOrgUser
); );
authenticated.post(
"/org/:orgId/user/:userId",
verifyOrgAccess,
verifyUserAccess,
verifyUserHasAction(ActionsEnum.updateOrgUser),
user.updateOrgUser
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.post( authenticated.post(
@ -932,7 +946,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 15, max: 15,
keyGenerator: (req) => keyGenerator: (req) =>
`requestEmailVerificationCode:${req.body.email || ipKeyGenerator(req.ip || "")}`, `requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => { handler: (req, res, next) => {
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));

View file

@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, idpOidcConfig } from "@server/db";
import { domains, idp, orgDomains, users, idpOrg } from "@server/db"; import { domains, idp, orgDomains, users, idpOrg } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@ -33,23 +33,21 @@ async function query(limit: number, offset: number) {
idpId: idp.idpId, idpId: idp.idpId,
name: idp.name, name: idp.name,
type: idp.type, type: idp.type,
orgCount: sql<number>`count(${idpOrg.orgId})` variant: idpOidcConfig.variant,
orgCount: sql<number>`count(${idpOrg.orgId})`,
autoProvision: idp.autoProvision
}) })
.from(idp) .from(idp)
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
.groupBy(idp.idpId) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.groupBy(idp.idpId, idpOidcConfig.variant)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return res; return res;
} }
export type ListIdpsResponse = { export type ListIdpsResponse = {
idps: Array<{ idps: Awaited<ReturnType<typeof query>>;
idpId: number;
name: string;
type: string;
orgCount: number;
}>;
pagination: { pagination: {
total: number; total: number;
limit: number; limit: number;

View file

@ -24,7 +24,8 @@ import {
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
verifyApiKeyClientAccess, verifyApiKeyClientAccess,
verifyClientsEnabled, verifyClientsEnabled,
verifyApiKeySiteResourceAccess verifyApiKeySiteResourceAccess,
verifyOrgAccess
} from "@server/middlewares"; } from "@server/middlewares";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { Router } from "express"; import { Router } from "express";
@ -469,6 +470,21 @@ authenticated.get(
user.listUsers user.listUsers
); );
authenticated.put(
"/org/:orgId/user",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
user.createOrgUser
);
authenticated.post(
"/org/:orgId/user/:userId",
verifyApiKeyOrgAccess,
verifyApiKeyUserAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
user.updateOrgUser
);
authenticated.delete( authenticated.delete(
"/org/:orgId/user/:userId", "/org/:orgId/user/:userId",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
@ -628,3 +644,10 @@ authenticated.post(
verifyApiKeyHasAction(ActionsEnum.updateClient), verifyApiKeyHasAction(ActionsEnum.updateClient),
client.updateClient client.updateClient
); );
authenticated.put(
"/org/:orgId/blueprint",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
org.applyBlueprint
);

View file

@ -0,0 +1,73 @@
import { db, newts } from "@server/db";
import { MessageHandler } from "../ws";
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
import { eq, and, sql, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint";
export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
const newt = client as Newt;
logger.debug("Handling apply blueprint message!");
if (!newt) {
logger.warn("Newt not found");
return;
}
if (!newt.siteId) {
logger.warn("Newt has no site!"); // TODO: Maybe we create the site here?
return;
}
// get the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, newt.siteId));
if (!site) {
logger.warn("Site not found for newt");
return;
}
const { blueprint } = message.data;
if (!blueprint) {
logger.warn("No blueprint provided");
return;
}
logger.debug(`Received blueprint: ${blueprint}`);
try {
const blueprintParsed = JSON.parse(blueprint);
// Update the blueprint in the database
await applyBlueprint(site.orgId, blueprintParsed, site.siteId);
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
return {
message: {
type: "newt/blueprint/results",
data: {
success: false,
message: `Failed to update database from config: ${error}`
}
},
broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast
};
}
return {
message: {
type: "newt/blueprint/results",
data: {
success: true,
message: "Config updated successfully"
}
},
broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast
};
};

View file

@ -10,6 +10,7 @@ import {
getNextAvailableClientSubnet getNextAvailableClientSubnet
} from "@server/lib/ip"; } from "@server/lib/ip";
import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes"; import { selectBestExitNode, verifyExitNodeOrgAccess } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket";
export type ExitNodePingResult = { export type ExitNodePingResult = {
exitNodeId: number; exitNodeId: number;
@ -76,6 +77,15 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
return; return;
} }
logger.debug(`Docker socket enabled: ${oldSite.dockerSocketEnabled}`);
if (oldSite.dockerSocketEnabled) {
logger.debug(
"Site has docker socket enabled - requesting docker containers"
);
fetchContainers(newt.newtId);
}
let siteSubnet = oldSite.subnet; let siteSubnet = oldSite.subnet;
let exitNodeIdToQuery = oldSite.exitNodeId; let exitNodeIdToQuery = oldSite.exitNodeId;
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {

View file

@ -2,6 +2,7 @@ import { MessageHandler } from "../ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { dockerSocketCache } from "./dockerSocket"; import { dockerSocketCache } from "./dockerSocket";
import { Newt } from "@server/db"; import { Newt } from "@server/db";
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
export const handleDockerStatusMessage: MessageHandler = async (context) => { export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@ -57,4 +58,15 @@ export const handleDockerContainersMessage: MessageHandler = async (
} else { } else {
logger.warn(`Newt ${newt.newtId} does not have Docker containers`); logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
} }
if (!newt.siteId) {
logger.warn("Newt has no site!");
return;
}
await applyNewtDockerBlueprint(
newt.siteId,
newt.newtId,
containers
);
}; };

View file

@ -4,4 +4,5 @@ export * from "./handleNewtRegisterMessage";
export * from "./handleReceiveBandwidthMessage"; export * from "./handleReceiveBandwidthMessage";
export * from "./handleGetConfigMessage"; export * from "./handleGetConfigMessage";
export * from "./handleSocketMessages"; export * from "./handleSocketMessages";
export * from "./handleNewtPingRequestMessage"; export * from "./handleNewtPingRequestMessage";
export * from "./handleApplyBlueprintMessage";

View file

@ -0,0 +1,127 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import {
apiKeyOrg,
apiKeys,
domains,
Org,
orgDomains,
orgs,
roleActions,
roles,
userOrgs,
users,
actions
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import config from "@server/lib/config";
import { fromError } from "zod-validation-error";
import { defaultRoleAllowedActions } from "../role";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
import { applyBlueprint as applyBlueprintFunc } from "@server/lib/blueprints/applyBlueprint";
const applyBlueprintSchema = z
.object({
blueprint: z.string()
})
.strict();
const applyBlueprintParamsSchema = z
.object({
orgId: z.string()
})
.strict();
registry.registerPath({
method: "put",
path: "/org/{orgId}/blueprint",
description: "Apply a base64 encoded blueprint to an organization",
tags: [OpenAPITags.Org],
request: {
params: applyBlueprintParamsSchema,
body: {
content: {
"application/json": {
schema: applyBlueprintSchema
}
}
}
},
responses: {}
});
export async function applyBlueprint(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = applyBlueprintParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = applyBlueprintSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { blueprint } = parsedBody.data;
if (!blueprint) {
logger.warn("No blueprint provided");
return;
}
logger.debug(`Received blueprint: ${blueprint}`);
try {
// first base64 decode the blueprint
const decoded = Buffer.from(blueprint, "base64").toString("utf-8");
// then parse the json
const blueprintParsed = JSON.parse(decoded);
// Update the blueprint in the database
await applyBlueprintFunc(orgId, blueprintParsed);
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Failed to update database from config: ${error}`
)
);
}
return response(res, {
data: null,
success: true,
error: false,
message: "Blueprint applied successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -7,3 +7,4 @@ export * from "./checkId";
export * from "./getOrgOverview"; export * from "./getOrgOverview";
export * from "./listOrgs"; export * from "./listOrgs";
export * from "./pickOrgDefaults"; export * from "./pickOrgDefaults";
export * from "./applyBlueprint";

View file

@ -21,6 +21,8 @@ import { subdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build"; import { build } from "@server/build";
import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
const createResourceParamsSchema = z const createResourceParamsSchema = z
.object({ .object({
@ -193,76 +195,21 @@ async function createHttpResource(
} }
const { name, domainId } = parsedBody.data; const { name, domainId } = parsedBody.data;
let subdomain = parsedBody.data.subdomain; const subdomain = parsedBody.data.subdomain;
const [domainRes] = await db // Validate domain and construct full domain
.select() const domainResult = await validateAndConstructDomain(domainId, orgId, subdomain);
.from(domains)
.where(eq(domains.domainId, domainId))
.leftJoin(
orgDomains,
and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId))
);
if (!domainRes || !domainRes.domains) { if (!domainResult.success) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found`
)
);
}
if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Organization does not have access to domain with ID ${domainId}`
)
);
}
if (!domainRes.domains.verified) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
`Domain with ID ${domainRes.domains.domainId} is not verified` domainResult.error
) )
); );
} }
let fullDomain = ""; const { fullDomain, subdomain: finalSubdomain } = domainResult;
if (domainRes.domains.type == "ns") {
if (subdomain) {
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
}
} else if (domainRes.domains.type == "cname") {
fullDomain = domainRes.domains.baseDomain;
} else if (domainRes.domains.type == "wildcard") {
if (subdomain) {
// the subdomain cant have a dot in it
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedSubdomain.error).toString()
)
);
}
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
}
}
if (fullDomain === domainRes.domains.baseDomain) {
subdomain = null;
}
fullDomain = fullDomain.toLowerCase();
logger.debug(`Full domain: ${fullDomain}`); logger.debug(`Full domain: ${fullDomain}`);
@ -283,15 +230,18 @@ async function createHttpResource(
let resource: Resource | undefined; let resource: Resource | undefined;
const niceId = await getUniqueResourceName(orgId);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const newResource = await trx const newResource = await trx
.insert(resources) .insert(resources)
.values({ .values({
niceId,
fullDomain, fullDomain,
domainId, domainId,
orgId, orgId,
name, name,
subdomain, subdomain: finalSubdomain,
http: true, http: true,
protocol: "tcp", protocol: "tcp",
ssl: true ssl: true
@ -391,10 +341,13 @@ async function createRawResource(
let resource: Resource | undefined; let resource: Resource | undefined;
const niceId = await getUniqueResourceName(orgId);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const newResource = await trx const newResource = await trx
.insert(resources) .insert(resources)
.values({ .values({
niceId,
orgId, orgId,
name, name,
http, http,

View file

@ -2,32 +2,72 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { Resource, resources, sites } from "@server/db"; import { Resource, resources, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
const getResourceSchema = z const getResourceSchema = z
.object({ .object({
resourceId: z resourceId: z
.string() .string()
.transform(Number) .optional()
.pipe(z.number().int().positive()) .transform(stoi)
.pipe(z.number().int().positive().optional())
.optional(),
niceId: z.string().optional(),
orgId: z.string().optional()
}) })
.strict(); .strict();
export type GetResourceResponse = Resource; async function query(resourceId?: number, niceId?: string, orgId?: string) {
if (resourceId) {
const [res] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
return res;
} else if (niceId && orgId) {
const [res] = await db
.select()
.from(resources)
.where(and(eq(resources.niceId, niceId), eq(resources.orgId, orgId)))
.limit(1);
return res;
}
}
export type GetResourceResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource/{niceId}",
description:
"Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
tags: [OpenAPITags.Org, OpenAPITags.Resource],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {}
});
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/resource/{resourceId}", path: "/resource/{resourceId}",
description: "Get a resource.", description: "Get a resource by resourceId.",
tags: [OpenAPITags.Resource], tags: [OpenAPITags.Resource],
request: { request: {
params: getResourceSchema params: z.object({
resourceId: z.number()
})
}, },
responses: {} responses: {}
}); });
@ -48,29 +88,18 @@ export async function getResource(
); );
} }
const { resourceId } = parsedParams.data; const { resourceId, niceId, orgId } = parsedParams.data;
const [resp] = await db const resource = await query(resourceId, niceId, orgId);
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
const resource = resp;
if (!resource) { if (!resource) {
return next( return next(
createHttpError( createHttpError(HttpCode.NOT_FOUND, "Resource not found")
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
); );
} }
return response(res, { return response<GetResourceResponse>(res, {
data: { data: resource,
...resource
},
success: true, success: true,
error: false, error: false,
message: "Resource retrieved successfully", message: "Resource retrieved successfully",

View file

@ -32,6 +32,7 @@ export type GetResourceAuthInfoResponse = {
url: string; url: string;
whitelist: boolean; whitelist: boolean;
skipToIdpId: number | null; skipToIdpId: number | null;
orgId: string;
}; };
export async function getResourceAuthInfo( export async function getResourceAuthInfo(
@ -88,7 +89,8 @@ export async function getResourceAuthInfo(
blockAccess: resource.blockAccess, blockAccess: resource.blockAccess,
url, url,
whitelist: resource.emailWhitelistEnabled, whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId
}, },
success: true, success: true,
error: false, error: false,

View file

@ -16,6 +16,7 @@ import logger from "@server/logger";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { warn } from "console";
const listResourcesParamsSchema = z const listResourcesParamsSchema = z
.object({ .object({
@ -54,7 +55,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
protocol: resources.protocol, protocol: resources.protocol,
proxyPort: resources.proxyPort, proxyPort: resources.proxyPort,
enabled: resources.enabled, enabled: resources.enabled,
domainId: resources.domainId domainId: resources.domainId,
niceId: resources.niceId
}) })
.from(resources) .from(resources)
.leftJoin( .leftJoin(

View file

@ -20,6 +20,8 @@ import { tlsNameSchema } from "@server/lib/schemas";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { validateHeaders } from "@server/lib/validators";
const updateResourceParamsSchema = z const updateResourceParamsSchema = z
.object({ .object({
@ -44,7 +46,8 @@ const updateHttpResourceBodySchema = z
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
tlsServerName: z.string().nullable().optional(), tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(),
skipToIdpId: z.number().int().positive().nullable().optional() skipToIdpId: z.number().int().positive().nullable().optional(),
headers: z.string().nullable().optional()
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
@ -82,6 +85,18 @@ const updateHttpResourceBodySchema = z
message: message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
} }
)
.refine(
(data) => {
if (data.headers) {
return validateHeaders(data.headers);
}
return true;
},
{
message:
"Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons."
}
); );
export type UpdateResourceResponse = Resource; export type UpdateResourceResponse = Resource;
@ -230,78 +245,19 @@ async function updateHttpResource(
if (updateData.domainId) { if (updateData.domainId) {
const domainId = updateData.domainId; const domainId = updateData.domainId;
const [domainRes] = await db // Validate domain and construct full domain
.select() const domainResult = await validateAndConstructDomain(domainId, resource.orgId, updateData.subdomain);
.from(domains)
.where(eq(domains.domainId, domainId)) if (!domainResult.success) {
.leftJoin(
orgDomains,
and(
eq(orgDomains.orgId, resource.orgId),
eq(orgDomains.domainId, domainId)
)
);
if (!domainRes || !domainRes.domains) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${updateData.domainId} not found`
)
);
}
if (
domainRes.orgDomains &&
domainRes.orgDomains.orgId !== resource.orgId
) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`You do not have permission to use domain with ID ${updateData.domainId}`
)
);
}
if (!domainRes.domains.verified) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
`Domain with ID ${updateData.domainId} is not verified` domainResult.error
) )
); );
} }
let fullDomain = ""; const { fullDomain, subdomain: finalSubdomain } = domainResult;
if (domainRes.domains.type == "ns") {
if (updateData.subdomain) {
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
}
} else if (domainRes.domains.type == "cname") {
fullDomain = domainRes.domains.baseDomain;
} else if (domainRes.domains.type == "wildcard") {
if (updateData.subdomain !== undefined) {
// the subdomain cant have a dot in it
const parsedSubdomain = subdomainSchema.safeParse(
updateData.subdomain
);
if (!parsedSubdomain.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedSubdomain.error).toString()
)
);
}
fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
}
}
fullDomain = fullDomain.toLowerCase();
logger.debug(`Full domain: ${fullDomain}`); logger.debug(`Full domain: ${fullDomain}`);
@ -332,9 +288,8 @@ async function updateHttpResource(
.where(eq(resources.resourceId, resource.resourceId)); .where(eq(resources.resourceId, resource.resourceId));
} }
if (fullDomain === domainRes.domains.baseDomain) { // Update the subdomain in the update data
updateData.subdomain = null; updateData.subdomain = finalSubdomain;
}
} }
const updatedResource = await db const updatedResource = await db

View file

@ -139,7 +139,7 @@ export async function pickSiteDefaults(
}, },
success: true, success: true,
error: false, error: false,
message: "Organization retrieved successfully", message: "Site defaults chosen successfully",
status: HttpCode.OK status: HttpCode.OK
}); });
} catch (error) { } catch (error) {

View file

@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { addTargets } from "../client/targets"; import { addTargets } from "../client/targets";
import { getUniqueSiteResourceName } from "@server/db/names";
const createSiteResourceParamsSchema = z const createSiteResourceParamsSchema = z
.object({ .object({
@ -121,11 +122,14 @@ export async function createSiteResource(
); );
} }
const niceId = await getUniqueSiteResourceName(orgId);
// Create the site resource // Create the site resource
const [newSiteResource] = await db const [newSiteResource] = await db
.insert(siteResources) .insert(siteResources)
.values({ .values({
siteId, siteId,
niceId,
orgId, orgId,
name, name,
protocol, protocol,

View file

@ -12,21 +12,72 @@ import { OpenAPITags, registry } from "@server/openApi";
const getSiteResourceParamsSchema = z const getSiteResourceParamsSchema = z
.object({ .object({
siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()), siteResourceId: z
.string()
.optional()
.transform((val) => val ? Number(val) : undefined)
.pipe(z.number().int().positive().optional())
.optional(),
siteId: z.string().transform(Number).pipe(z.number().int().positive()), siteId: z.string().transform(Number).pipe(z.number().int().positive()),
niceId: z.string().optional(),
orgId: z.string() orgId: z.string()
}) })
.strict(); .strict();
export type GetSiteResourceResponse = SiteResource; async function query(siteResourceId?: number, siteId?: number, niceId?: string, orgId?: string) {
if (siteResourceId && siteId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
))
.limit(1);
return siteResource;
} else if (niceId && siteId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(and(
eq(siteResources.niceId, niceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
))
.limit(1);
return siteResource;
}
}
export type GetSiteResourceResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}",
description: "Get a specific site resource.", description: "Get a specific site resource by siteResourceId.",
tags: [OpenAPITags.Client, OpenAPITags.Org], tags: [OpenAPITags.Client, OpenAPITags.Org],
request: { request: {
params: getSiteResourceParamsSchema params: z.object({
siteResourceId: z.number(),
siteId: z.number(),
orgId: z.string()
})
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}",
description: "Get a specific site resource by niceId.",
tags: [OpenAPITags.Client, OpenAPITags.Org],
request: {
params: z.object({
niceId: z.string(),
siteId: z.number(),
orgId: z.string()
})
}, },
responses: {} responses: {}
}); });
@ -47,18 +98,10 @@ export async function getSiteResource(
); );
} }
const { siteResourceId, siteId, orgId } = parsedParams.data; const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
// Get the site resource // Get the site resource
const [siteResource] = await db const siteResource = await query(siteResourceId, siteId, niceId, orgId);
.select()
.from(siteResources)
.where(and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
))
.limit(1);
if (!siteResource) { if (!siteResource) {
return next( return next(

View file

@ -28,7 +28,7 @@ const updateSiteResourceSchema = z
protocol: z.enum(["tcp", "udp"]).optional(), protocol: z.enum(["tcp", "udp"]).optional(),
proxyPort: z.number().int().positive().optional(), proxyPort: z.number().int().positive().optional(),
destinationPort: z.number().int().positive().optional(), destinationPort: z.number().int().positive().optional(),
destinationIp: z.string().ip().optional(), destinationIp: z.string().optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional()
}) })
.strict(); .strict();

View file

@ -30,7 +30,9 @@ const createTargetSchema = z
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
method: z.string().optional().nullable(), method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
enabled: z.boolean().default(true) enabled: z.boolean().default(true),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
}) })
.strict(); .strict();
@ -161,7 +163,7 @@ export async function createTarget(
); );
} }
const { internalPort, targetIps } = await pickPort(site.siteId!); const { internalPort, targetIps } = await pickPort(site.siteId!, db);
if (!internalPort) { if (!internalPort) {
return next( return next(

View file

@ -1,10 +1,10 @@
import { db } from "@server/db"; import { db, Transaction } from "@server/db";
import { resources, targets } from "@server/db"; import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
const currentBannedPorts: number[] = []; const currentBannedPorts: number[] = [];
export async function pickPort(siteId: number): Promise<{ export async function pickPort(siteId: number, trx: Transaction | typeof db): Promise<{
internalPort: number; internalPort: number;
targetIps: string[]; targetIps: string[];
}> { }> {
@ -12,7 +12,7 @@ export async function pickPort(siteId: number): Promise<{
const targetIps: string[] = []; const targetIps: string[] = [];
const targetInternalPorts: number[] = []; const targetInternalPorts: number[] = [];
const targetsRes = await db const targetsRes = await trx
.select() .select()
.from(targets) .from(targets)
.where(eq(targets.siteId, siteId)); .where(eq(targets.siteId, siteId));

View file

@ -44,7 +44,9 @@ function queryTargets(resourceId: number) {
enabled: targets.enabled, enabled: targets.enabled,
resourceId: targets.resourceId, resourceId: targets.resourceId,
siteId: targets.siteId, siteId: targets.siteId,
siteType: sites.type siteType: sites.type,
path: targets.path,
pathMatchType: targets.pathMatchType
}) })
.from(targets) .from(targets)
.leftJoin(sites, eq(sites.siteId, targets.siteId)) .leftJoin(sites, eq(sites.siteId, targets.siteId))

View file

@ -26,7 +26,9 @@ const updateTargetBodySchema = z
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
method: z.string().min(1).max(10).optional().nullable(), method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable()
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
@ -153,7 +155,7 @@ export async function updateTarget(
); );
} }
const { internalPort, targetIps } = await pickPort(site.siteId!); const { internalPort, targetIps } = await pickPort(site.siteId!, db);
if (!internalPort) { if (!internalPort) {
return next( return next(

View file

@ -54,7 +54,8 @@ export async function traefikConfigProvider(
config.getRawConfig().traefik.site_types config.getRawConfig().traefik.site_types
); );
if (traefikConfig?.http?.middlewares) { // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING if (traefikConfig?.http?.middlewares) {
// BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING
traefikConfig.http.middlewares[badgerMiddlewareName] = { traefikConfig.http.middlewares[badgerMiddlewareName] = {
plugin: { plugin: {
[badgerMiddlewareName]: { [badgerMiddlewareName]: {
@ -104,106 +105,112 @@ export async function getTraefikConfig(
}; };
}; };
// Get all resources with related data // Get resources with their targets and sites in a single optimized query
const allResources = await db.transaction(async (tx) => { // Start from sites on this exit node, then join to targets and resources
// Get resources with their targets and sites in a single optimized query const resourcesWithTargetsAndSites = await db
// Start from sites on this exit node, then join to targets and resources .select({
const resourcesWithTargetsAndSites = await tx // Resource fields
.select({ resourceId: resources.resourceId,
// Resource fields fullDomain: resources.fullDomain,
resourceId: resources.resourceId, ssl: resources.ssl,
fullDomain: resources.fullDomain, http: resources.http,
ssl: resources.ssl, proxyPort: resources.proxyPort,
http: resources.http, protocol: resources.protocol,
proxyPort: resources.proxyPort, subdomain: resources.subdomain,
protocol: resources.protocol, domainId: resources.domainId,
subdomain: resources.subdomain, enabled: resources.enabled,
domainId: resources.domainId, stickySession: resources.stickySession,
enabled: resources.enabled, tlsServerName: resources.tlsServerName,
stickySession: resources.stickySession, setHostHeader: resources.setHostHeader,
tlsServerName: resources.tlsServerName, enableProxy: resources.enableProxy,
setHostHeader: resources.setHostHeader, headers: resources.headers,
enableProxy: resources.enableProxy, // Target fields
// Target fields targetId: targets.targetId,
targetId: targets.targetId, targetEnabled: targets.enabled,
targetEnabled: targets.enabled, ip: targets.ip,
ip: targets.ip, method: targets.method,
method: targets.method, port: targets.port,
port: targets.port, internalPort: targets.internalPort,
internalPort: targets.internalPort, path: targets.path,
// Site fields pathMatchType: targets.pathMatchType,
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
exitNodeId: sites.exitNodeId
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.where(
and(
eq(targets.enabled, true),
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
isNull(sites.exitNodeId)
),
inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true),
)
);
// Group by resource and include targets with their unique site data // Site fields
const resourcesMap = new Map(); siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
exitNodeId: sites.exitNodeId
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.where(
and(
eq(targets.enabled, true),
eq(resources.enabled, true),
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)),
inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
)
);
resourcesWithTargetsAndSites.forEach((row) => { // Group by resource and include targets with their unique site data
const resourceId = row.resourceId; const resourcesMap = new Map();
if (!resourcesMap.has(resourceId)) { resourcesWithTargetsAndSites.forEach((row) => {
resourcesMap.set(resourceId, { const resourceId = row.resourceId;
resourceId: row.resourceId, const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
fullDomain: row.fullDomain, const pathMatchType = row.pathMatchType || "";
ssl: row.ssl,
http: row.http,
proxyPort: row.proxyPort,
protocol: row.protocol,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
stickySession: row.stickySession,
tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
targets: []
});
}
// Add target with its associated site data // Create a unique key combining resourceId and path+pathMatchType
resourcesMap.get(resourceId).targets.push({ const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
if (!resourcesMap.has(mapKey)) {
resourcesMap.set(mapKey, {
resourceId: row.resourceId, resourceId: row.resourceId,
targetId: row.targetId, fullDomain: row.fullDomain,
ip: row.ip, ssl: row.ssl,
method: row.method, http: row.http,
port: row.port, proxyPort: row.proxyPort,
internalPort: row.internalPort, protocol: row.protocol,
enabled: row.targetEnabled, subdomain: row.subdomain,
site: { domainId: row.domainId,
siteId: row.siteId, enabled: row.enabled,
type: row.siteType, stickySession: row.stickySession,
subnet: row.subnet, tlsServerName: row.tlsServerName,
exitNodeId: row.exitNodeId, setHostHeader: row.setHostHeader,
online: row.siteOnline enableProxy: row.enableProxy,
} targets: [],
headers: row.headers,
path: row.path, // the targets will all have the same path
pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType
}); });
}); }
return Array.from(resourcesMap.values()); // Add target with its associated site data
resourcesMap.get(mapKey).targets.push({
resourceId: row.resourceId,
targetId: row.targetId,
ip: row.ip,
method: row.method,
port: row.port,
internalPort: row.internalPort,
enabled: row.targetEnabled,
site: {
siteId: row.siteId,
type: row.siteType,
subnet: row.subnet,
exitNodeId: row.exitNodeId,
online: row.siteOnline
}
});
}); });
if (!allResources.length) { // make sure we have at least one resource
if (resourcesMap.size === 0) {
return {}; return {};
} }
@ -219,14 +226,15 @@ export async function getTraefikConfig(
} }
}; };
for (const resource of allResources) { // get the key and the resource
for (const [key, resource] of resourcesMap.entries()) {
const targets = resource.targets; const targets = resource.targets;
const routerName = `${resource.resourceId}-router`; const routerName = `${key}-router`;
const serviceName = `${resource.resourceId}-service`; const serviceName = `${key}-service`;
const fullDomain = `${resource.fullDomain}`; const fullDomain = `${resource.fullDomain}`;
const transportName = `${resource.resourceId}-transport`; const transportName = `${key}-transport`;
const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`; const headersMiddlewareName = `${key}-headers-middleware`;
if (!resource.enabled) { if (!resource.enabled) {
continue; continue;
@ -238,9 +246,6 @@ export async function getTraefikConfig(
} }
if (!resource.fullDomain) { if (!resource.fullDomain) {
logger.error(
`Resource ${resource.resourceId} has no fullDomain`
);
continue; continue;
} }
@ -296,16 +301,68 @@ export async function getTraefikConfig(
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
if (resource.headers && resource.headers.length > 0) {
// if there are headers, parse them into an object
const headersObj: { [key: string]: string } = {};
const headersArr = resource.headers.split(",");
for (const header of headersArr) {
const [key, value] = header
.split(":")
.map((s: string) => s.trim());
if (key && value) {
headersObj[key] = value;
}
}
if (resource.setHostHeader) {
headersObj["Host"] = resource.setHostHeader;
}
// check if the object is not empty
if (Object.keys(headersObj).length > 0) {
// Add the headers middleware
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] = {
headers: {
customRequestHeaders: headersObj
}
};
routerMiddlewares.push(headersMiddlewareName);
}
}
let rule = `Host(\`${fullDomain}\`)`;
let priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 1;
// add path to rule based on match type
if (resource.pathMatchType === "exact") {
rule += ` && Path(\`${resource.path}\`)`;
} else if (resource.pathMatchType === "prefix") {
rule += ` && PathPrefix(\`${resource.path}\`)`;
} else if (resource.pathMatchType === "regex") {
rule += ` && PathRegexp(\`${resource.path}\`)`;
}
}
config_output.http.routers![routerName] = { config_output.http.routers![routerName] = {
entryPoints: [ entryPoints: [
resource.ssl resource.ssl
? config.getRawConfig().traefik.https_entrypoint ? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint : config.getRawConfig().traefik.http_entrypoint
], ],
middlewares: [badgerMiddlewareName, ...additionalMiddlewares], middlewares: routerMiddlewares,
service: serviceName, service: serviceName,
rule: `Host(\`${fullDomain}\`)`, rule: rule,
priority: 100, priority: priority,
...(resource.ssl ? { tls } : {}) ...(resource.ssl ? { tls } : {})
}; };
@ -316,8 +373,8 @@ export async function getTraefikConfig(
], ],
middlewares: [redirectHttpsMiddlewareName], middlewares: [redirectHttpsMiddlewareName],
service: serviceName, service: serviceName,
rule: `Host(\`${fullDomain}\`)`, rule: rule,
priority: 100 priority: priority
}; };
} }
@ -334,55 +391,64 @@ export async function getTraefikConfig(
targets as TargetWithSite[] targets as TargetWithSite[]
).some((target: TargetWithSite) => target.site.online); ).some((target: TargetWithSite) => target.site.online);
return (targets as TargetWithSite[]) return (
.filter((target: TargetWithSite) => { (targets as TargetWithSite[])
if (!target.enabled) { .filter((target: TargetWithSite) => {
return false; if (!target.enabled) {
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false; return false;
} }
} else if (target.site.type === "newt") {
if ( // If any sites are online, exclude offline sites
!target.internalPort || if (anySitesOnline && !target.site.online) {
!target.method ||
!target.site.subnet
) {
return false; return false;
} }
}
return true; if (
}) target.site.type === "local" ||
.map((target: TargetWithSite) => { target.site.type === "wireguard"
if ( ) {
target.site.type === "local" || if (
target.site.type === "wireguard" !target.ip ||
) { !target.port ||
return { !target.method
url: `${target.method}://${target.ip}:${target.port}` ) {
}; return false;
} else if (target.site.type === "newt") { }
const ip = } else if (target.site.type === "newt") {
target.site.subnet!.split("/")[0]; if (
return { !target.internalPort ||
url: `${target.method}://${ip}:${target.internalPort}` !target.method ||
}; !target.site.subnet
} ) {
}); return false;
}
}
return true;
})
.map((target: TargetWithSite) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
// filter out duplicates
.filter(
(v, i, a) =>
a.findIndex(
(t) => t && v && t.url === v.url
) === i
)
);
})(), })(),
...(resource.stickySession ...(resource.stickySession
? { ? {
@ -413,27 +479,6 @@ export async function getTraefikConfig(
serviceName serviceName
].loadBalancer.serversTransport = transportName; ].loadBalancer.serversTransport = transportName;
} }
// Add the host header middleware
if (resource.setHostHeader) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[hostHeaderMiddlewareName] = {
headers: {
customRequestHeaders: {
Host: resource.setHostHeader
}
}
};
if (!config_output.http.routers![routerName].middlewares) {
config_output.http.routers![routerName].middlewares = [];
}
config_output.http.routers![routerName].middlewares = [
...config_output.http.routers![routerName].middlewares,
hostHeaderMiddlewareName
];
}
} else { } else {
// Non-HTTP (TCP/UDP) configuration // Non-HTTP (TCP/UDP) configuration
if (!resource.enableProxy) { if (!resource.enableProxy) {
@ -529,3 +574,13 @@ export async function getTraefikConfig(
} }
return config_output; return config_output;
} }
function sanitizePath(path: string | null | undefined): string | undefined {
if (!path) return undefined;
// clean any non alphanumeric characters from the path and replace with dashes
// the path cant be too long either, so limit to 50 characters
if (path.length > 50) {
path = path.substring(0, 50);
}
return path.replace(/[^a-zA-Z0-9]/g, "");
}

View file

@ -84,7 +84,14 @@ export async function createOrgUser(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { username, email, name, type, idpId, roleId } = parsedBody.data; const {
username,
email,
name,
type,
idpId,
roleId
} = parsedBody.data;
const [role] = await db const [role] = await db
.select() .select()
@ -141,7 +148,12 @@ export async function createOrgUser(
const [existingUser] = await trx const [existingUser] = await trx
.select() .select()
.from(users) .from(users)
.where(eq(users.username, username)); .where(
and(
eq(users.username, username),
eq(users.idpId, idpId)
)
);
if (existingUser) { if (existingUser) {
const [existingOrgUser] = await trx const [existingOrgUser] = await trx
@ -168,7 +180,8 @@ export async function createOrgUser(
.values({ .values({
orgId, orgId,
userId: existingUser.userId, userId: existingUser.userId,
roleId: role.roleId roleId: role.roleId,
autoProvisioned: false
}) })
.returning(); .returning();
} else { } else {
@ -184,7 +197,7 @@ export async function createOrgUser(
type: "oidc", type: "oidc",
idpId, idpId,
dateCreated: new Date().toISOString(), dateCreated: new Date().toISOString(),
emailVerified: true emailVerified: true,
}) })
.returning(); .returning();
@ -193,7 +206,8 @@ export async function createOrgUser(
.values({ .values({
orgId, orgId,
userId: newUser.userId, userId: newUser.userId,
roleId: role.roleId roleId: role.roleId,
autoProvisioned: false
}) })
.returning(); .returning();
} }
@ -204,7 +218,6 @@ export async function createOrgUser(
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.orgId, orgId)); .where(eq(userOrgs.orgId, orgId));
}); });
} else { } else {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required") createHttpError(HttpCode.BAD_REQUEST, "User type is required")

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, idp, idpOidcConfig } from "@server/db";
import { roles, userOrgs, users } from "@server/db"; import { roles, userOrgs, users } from "@server/db";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@ -25,10 +25,18 @@ async function queryUser(orgId: string, userId: string) {
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin, isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled, twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId,
idpName: idp.name,
idpType: idp.type,
idpVariant: idpOidcConfig.variant,
idpAutoProvision: idp.autoProvision
}) })
.from(userOrgs) .from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1); .limit(1);
if (typeof user.roles === "string") { if (typeof user.roles === "string") {

View file

@ -14,3 +14,4 @@ export * from "./removeInvitation";
export * from "./createOrgUser"; export * from "./createOrgUser";
export * from "./adminUpdateUser2FA"; export * from "./adminUpdateUser2FA";
export * from "./adminGetUser"; export * from "./adminGetUser";
export * from "./updateOrgUser";

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, idpOidcConfig } from "@server/db";
import { idp, roles, userOrgs, users } from "@server/db"; import { idp, roles, userOrgs, users } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -61,12 +61,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId, idpId: users.idpId,
idpType: idp.type,
idpVariant: idpOidcConfig.variant,
twoFactorEnabled: users.twoFactorEnabled, twoFactorEnabled: users.twoFactorEnabled,
}) })
.from(users) .from(users)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId)) .where(eq(userOrgs.orgId, orgId))
.groupBy(users.userId) .groupBy(users.userId)
.limit(limit) .limit(limit)

View file

@ -0,0 +1,112 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, userOrgs } from "@server/db";
import { and, eq } 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 { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z
.object({
userId: z.string(),
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
autoProvisioned: z.boolean().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/user/{userId}",
description: "Update a user in an org.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateOrgUser(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userId, orgId } = parsedParams.data;
const [existingUser] = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (!existingUser) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found in this organization"
)
);
}
const updateData = parsedBody.data;
const [updatedUser] = await db
.update(userOrgs)
.set({
...updateData
})
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.returning();
return response(res, {
data: updatedUser,
success: true,
error: false,
message: "Org user updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -4,7 +4,8 @@ import {
handleGetConfigMessage, handleGetConfigMessage,
handleDockerStatusMessage, handleDockerStatusMessage,
handleDockerContainersMessage, handleDockerContainersMessage,
handleNewtPingRequestMessage handleNewtPingRequestMessage,
handleApplyBlueprintMessage
} from "../newt"; } from "../newt";
import { import {
handleOlmRegisterMessage, handleOlmRegisterMessage,
@ -23,7 +24,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/ping": handleOlmPingMessage, "olm/ping": handleOlmPingMessage,
"newt/socket/status": handleDockerStatusMessage, "newt/socket/status": handleDockerStatusMessage,
"newt/socket/containers": handleDockerContainersMessage, "newt/socket/containers": handleDockerContainersMessage,
"newt/ping/request": handleNewtPingRequestMessage "newt/ping/request": handleNewtPingRequestMessage,
"newt/blueprint/apply": handleApplyBlueprintMessage,
}; };
startOlmOfflineChecker(); // this is to handle the offline check for olms startOlmOfflineChecker(); // this is to handle the offline check for olms

View file

@ -9,6 +9,7 @@ import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0"; import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0"; import m3 from "./scriptsPg/1.8.0";
import m4 from "./scriptsPg/1.9.0"; import m4 from "./scriptsPg/1.9.0";
import m5 from "./scriptsPg/1.10.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -18,7 +19,8 @@ const migrations = [
{ version: "1.6.0", run: m1 }, { version: "1.6.0", run: m1 },
{ version: "1.7.0", run: m2 }, { version: "1.7.0", run: m2 },
{ version: "1.8.0", run: m3 }, { version: "1.8.0", run: m3 },
{ version: "1.9.0", run: m4 } { version: "1.9.0", run: m4 },
{ version: "1.10.0", run: m5 },
// Add new migrations here as they are created // Add new migrations here as they are created
] as { ] as {
version: string; version: string;

View file

@ -26,6 +26,8 @@ import m21 from "./scriptsSqlite/1.6.0";
import m22 from "./scriptsSqlite/1.7.0"; import m22 from "./scriptsSqlite/1.7.0";
import m23 from "./scriptsSqlite/1.8.0"; import m23 from "./scriptsSqlite/1.8.0";
import m24 from "./scriptsSqlite/1.9.0"; import m24 from "./scriptsSqlite/1.9.0";
import m25 from "./scriptsSqlite/1.10.0";
import m26 from "./scriptsSqlite/1.10.1";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -51,6 +53,8 @@ const migrations = [
{ version: "1.7.0", run: m22 }, { version: "1.7.0", run: m22 },
{ version: "1.8.0", run: m23 }, { version: "1.8.0", run: m23 },
{ version: "1.9.0", run: m24 }, { version: "1.9.0", run: m24 },
{ version: "1.10.0", run: m25 },
{ version: "1.10.1", run: m26 },
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -0,0 +1,147 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import { readFileSync } from "fs";
import path, { join } from "path";
const version = "1.10.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
const resources = await db.execute(sql`
SELECT "resourceId" FROM "resources"
`);
const siteResources = await db.execute(sql`
SELECT "siteResourceId" FROM "siteResources"
`);
await db.execute(sql`BEGIN`);
await db.execute(
sql`ALTER TABLE "exitNodes" ADD COLUMN "region" text;`
);
await db.execute(
sql`ALTER TABLE "idpOidcConfig" ADD COLUMN "variant" text DEFAULT 'oidc' NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "resources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "siteResources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "userOrgs" ADD COLUMN "autoProvisioned" boolean DEFAULT false;`
);
await db.execute(
sql`ALTER TABLE "targets" ADD COLUMN "pathMatchType" text;`
);
await db.execute(sql`ALTER TABLE "targets" ADD COLUMN "path" text;`);
await db.execute(
sql`ALTER TABLE "resources" ADD COLUMN "headers" text;`
);
const usedNiceIds: string[] = [];
for (const resource of resources.rows) {
// Generate a unique name and ensure it's unique
let niceId = "";
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
niceId = generateName();
if (!usedNiceIds.includes(niceId)) {
usedNiceIds.push(niceId);
break;
}
loops++;
}
await db.execute(sql`
UPDATE "resources" SET "niceId" = ${niceId} WHERE "resourceId" = ${resource.resourceId}
`);
}
for (const resource of siteResources.rows) {
// Generate a unique name and ensure it's unique
let niceId = "";
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
niceId = generateName();
if (!usedNiceIds.includes(niceId)) {
usedNiceIds.push(niceId);
break;
}
loops++;
}
await db.execute(sql`
UPDATE "siteResources" SET "niceId" = ${niceId} WHERE "siteResourceId" = ${resource.siteResourceId}
`);
}
// Handle auto-provisioned users for identity providers
const autoProvisionIdps = await db.execute(sql`
SELECT "idpId" FROM "idp" WHERE "autoProvision" = true
`);
for (const idp of autoProvisionIdps.rows) {
// Get all users with this identity provider
const usersWithIdp = await db.execute(sql`
SELECT "id" FROM "user" WHERE "idpId" = ${idp.idpId}
`);
// Update userOrgs to set autoProvisioned to true for these users
for (const user of usersWithIdp.rows) {
await db.execute(sql`
UPDATE "userOrgs" SET "autoProvisioned" = true WHERE "userId" = ${user.id}
`);
}
}
await db.execute(sql`COMMIT`);
console.log(`Migrated database`);
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Failed to migrate db:", e);
throw e;
}
}
const dev = process.env.ENVIRONMENT !== "prod";
let file;
if (!dev) {
file = join(__DIRNAME, "names.json");
} else {
file = join("server/db/names.json");
}
export const names = JSON.parse(readFileSync(file, "utf-8"));
export function generateName(): string {
const name = (
names.descriptors[
Math.floor(Math.random() * names.descriptors.length)
] +
"-" +
names.animals[Math.floor(Math.random() * names.animals.length)]
)
.toLowerCase()
.replace(/\s/g, "-");
// clean out any non-alphanumeric characters except for dashes
return name.replace(/[^a-z0-9-]/g, "");
}

View file

@ -0,0 +1,136 @@
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import { readFileSync } from "fs";
import path, { join } from "path";
const version = "1.10.0";
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 {
const resources = db
.prepare(
"SELECT resourceId FROM resources"
)
.all() as Array<{ resourceId: number }>;
const siteResources = db
.prepare(
"SELECT siteResourceId FROM siteResources"
)
.all() as Array<{ siteResourceId: number }>;
db.transaction(() => {
db.exec(`
ALTER TABLE 'exitNodes' ADD 'region' text;
ALTER TABLE 'idpOidcConfig' ADD 'variant' text DEFAULT 'oidc' NOT NULL;
ALTER TABLE 'resources' ADD 'niceId' text DEFAULT '' NOT NULL;
ALTER TABLE 'siteResources' ADD 'niceId' text DEFAULT '' NOT NULL;
ALTER TABLE 'userOrgs' ADD 'autoProvisioned' integer DEFAULT false;
ALTER TABLE 'targets' ADD 'pathMatchType' text;
ALTER TABLE 'targets' ADD 'path' text;
ALTER TABLE 'resources' ADD 'headers' text;
`); // this diverges from the schema a bit because the schema does not have a default on niceId but was required for the migration and I dont think it will effect much down the line...
const usedNiceIds: string[] = [];
for (const resourceId of resources) {
// Generate a unique name and ensure it's unique
let niceId = "";
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
niceId = generateName();
if (!usedNiceIds.includes(niceId)) {
usedNiceIds.push(niceId);
break;
}
loops++;
}
db.prepare(
`UPDATE resources SET niceId = ? WHERE resourceId = ?`
).run(niceId, resourceId.resourceId);
}
for (const resourceId of siteResources) {
// Generate a unique name and ensure it's unique
let niceId = "";
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
niceId = generateName();
if (!usedNiceIds.includes(niceId)) {
usedNiceIds.push(niceId);
break;
}
loops++;
}
db.prepare(
`UPDATE siteResources SET niceId = ? WHERE siteResourceId = ?`
).run(niceId, resourceId.siteResourceId);
}
// Handle auto-provisioned users for identity providers
const autoProvisionIdps = db
.prepare(
"SELECT idpId FROM idp WHERE autoProvision = 1"
)
.all() as Array<{ idpId: number }>;
for (const idp of autoProvisionIdps) {
// Get all users with this identity provider
const usersWithIdp = db
.prepare(
"SELECT id FROM user WHERE idpId = ?"
)
.all(idp.idpId) as Array<{ id: string }>;
// Update userOrgs to set autoProvisioned to true for these users
for (const user of usersWithIdp) {
db.prepare(
"UPDATE userOrgs SET autoProvisioned = 1 WHERE userId = ?"
).run(user.id);
}
}
})();
console.log(`Migrated database`);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
}
const dev = process.env.ENVIRONMENT !== "prod";
let file;
if (!dev) {
file = join(__DIRNAME, "names.json");
} else {
file = join("server/db/names.json");
}
export const names = JSON.parse(readFileSync(file, "utf-8"));
export function generateName(): string {
const name = (
names.descriptors[
Math.floor(Math.random() * names.descriptors.length)
] +
"-" +
names.animals[Math.floor(Math.random() * names.animals.length)]
)
.toLowerCase()
.replace(/\s/g, "-");
// clean out any non-alphanumeric characters except for dashes
return name.replace(/[^a-z0-9-]/g, "");
}

View file

@ -0,0 +1,69 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.10.1";
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.pragma("foreign_keys = OFF");
db.transaction(() => {
db.exec(`ALTER TABLE "targets" RENAME TO "targets_old";
--> statement-breakpoint
CREATE TABLE "targets" (
"targetId" INTEGER PRIMARY KEY AUTOINCREMENT,
"resourceId" INTEGER NOT NULL,
"siteId" INTEGER NOT NULL,
"ip" TEXT NOT NULL,
"method" TEXT,
"port" INTEGER NOT NULL,
"internalPort" INTEGER,
"enabled" INTEGER NOT NULL DEFAULT 1,
"path" TEXT,
"pathMatchType" TEXT,
FOREIGN KEY ("resourceId") REFERENCES "resources"("resourceId") ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ("siteId") REFERENCES "sites"("siteId") ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO "targets" (
"targetId",
"resourceId",
"siteId",
"ip",
"method",
"port",
"internalPort",
"enabled",
"path",
"pathMatchType"
)
SELECT
targetId,
resourceId,
siteId,
ip,
method,
port,
internalPort,
enabled,
path,
pathMatchType
FROM "targets_old";
--> statement-breakpoint
DROP TABLE "targets_old";`);
})();
db.pragma("foreign_keys = ON");
console.log(`Migrated database`);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
}

View file

@ -1,8 +1,8 @@
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { cache } from "react"; import { cache } from "react";
import OrganizationLandingCard from "./OrganizationLandingCard"; import OrganizationLandingCard from "../../components/OrganizationLandingCard";
import MemberResourcesPortal from "./MemberResourcesPortal"; import MemberResourcesPortal from "../../components/MemberResourcesPortal";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";

View file

@ -1,13 +1,13 @@
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import InvitationsTable, { InvitationRow } from "./InvitationsTable"; import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react"; import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';

View file

@ -1,7 +1,6 @@
interface AccessLayoutProps { interface AccessLayoutProps {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ params: Promise<{
resourceId: number | string;
orgId: string; orgId: string;
}>; }>;
} }

View file

@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react"; import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import RolesTable, { RoleRow } from "./RolesTable"; import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';

View file

@ -8,6 +8,7 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Checkbox } from "@app/components/ui/checkbox";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { SetUserRolesResponse } from "@server/routers/user"; import { SetUserRolesResponse } from "@server/routers/user";
@ -34,6 +35,8 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
export default function AccessControlsPage() { export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext(); const { orgUser: user } = userOrgUserContext();
@ -61,14 +64,16 @@ export default function AccessControlsPage() {
text: z.string() text: z.string()
}) })
) )
.min(1, { message: t('accessRoleSelectPlease') }) .min(1, { message: t('accessRoleSelectPlease') }),
autoProvisioned: z.boolean()
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
username: user.username!, username: user.username!,
roles: [] roles: [],
autoProvisioned: user.autoProvisioned || false
} }
}); });
@ -80,10 +85,10 @@ export default function AccessControlsPage() {
console.error(e); console.error(e);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t('accessRoleErrorFetch'), title: t("accessRoleErrorFetch"),
description: formatAxiosError( description: formatAxiosError(
e, e,
t('accessRoleErrorFetchDescription') t("accessRoleErrorFetchDescription")
) )
}); });
}); });
@ -107,31 +112,38 @@ export default function AccessControlsPage() {
text: i.name text: i.name
})) }))
); );
form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []); }, []);
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);
const res = await api try {
.post< // Execute both API calls simultaneously
AxiosResponse<SetUserRolesResponse> const [roleRes, userRes] = await Promise.all([
>(`/org/${user.orgId}/user/${user.userId}/roles`, { roleIds: values.roles.map((r) => parseInt(r.id)) }) api.post<AxiosResponse<SetUserRolesResponse>>(`/org/${user.orgId}/user/${user.userId}/roles`, {
.catch((e) => { roleIds: values.roles.map((r) => parseInt(r.id)) }
toast({ ),
variant: "destructive", api.post(`/org/${orgId}/user/${user.userId}`, {
title: t('accessRoleErrorAdd'), autoProvisioned: values.autoProvisioned
description: formatAxiosError( })
e, ]);
t('accessRoleErrorAddDescription')
)
});
});
if (res && res.status === 200) { if (roleRes.status === 200 && userRes.status === 200) {
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
}
} catch (e) {
toast({ toast({
variant: "default", variant: "destructive",
title: t('userSaved'), title: t("accessRoleErrorAdd"),
description: t('userSavedDescription') description: formatAxiosError(
e,
t("accessRoleErrorAddDescription")
)
}); });
} }
@ -142,9 +154,11 @@ export default function AccessControlsPage() {
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle>{t('accessControls')}</SettingsSectionTitle> <SettingsSectionTitle>
{t("accessControls")}
</SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t('accessControlsDescription')} {t("accessControlsDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
@ -156,12 +170,29 @@ export default function AccessControlsPage() {
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
{/* IDP Type Display */}
{user.type !== UserType.Internal &&
user.idpType && (
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm font-medium text-muted-foreground">
{t("idp")}:
</span>
<IdpTypeBadge
type={user.idpType}
variant={
user.idpVariant || undefined
}
name={user.idpName || undefined}
/>
</div>
)}
<FormField <FormField
control={form.control} control={form.control}
name="roles" name="roles"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel> <FormLabel>{t('roles')}</FormLabel>
<FormControl> <FormControl>
<TagInput <TagInput
{...field} {...field}
@ -184,6 +215,10 @@ export default function AccessControlsPage() {
...Tag[] ...Tag[]
] ]
); );
// If auto provision is enabled, set it to false when role changes
if (user.idpAutoProvision) {
form.setValue("autoProvisioned", false);
}
}} }}
enableAutocomplete={true} enableAutocomplete={true}
autocompleteOptions={ autocompleteOptions={
@ -200,6 +235,35 @@ export default function AccessControlsPage() {
</FormItem> </FormItem>
)} )}
/> />
{user.idpAutoProvision && (
<FormField
control={form.control}
name="autoProvisioned"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t("autoProvisioned")}
</FormLabel>
<p className="text-sm text-muted-foreground">
{t(
"autoProvisionedDescription"
)}
</p>
</div>
</FormItem>
)}
/>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
@ -212,7 +276,7 @@ export default function AccessControlsPage() {
disabled={loading} disabled={loading}
form="access-controls-form" form="access-controls-form"
> >
{t('accessControlsSubmit')} {t("accessControlsSubmit")}
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>

View file

@ -46,6 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp"; import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; import { build } from "@server/build";
import Image from "next/image";
type UserType = "internal" | "oidc"; type UserType = "internal" | "oidc";
@ -53,6 +54,17 @@ interface IdpOption {
idpId: number; idpId: number;
name: string; name: string;
type: string; type: string;
variant: string | null;
}
interface UserOption {
id: string;
title: string;
description: string;
disabled: boolean;
icon?: React.ReactNode;
idpId?: number;
variant?: string | null;
} }
export default function Page() { export default function Page() {
@ -62,14 +74,14 @@ export default function Page() {
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); const t = useTranslations();
const [userType, setUserType] = useState<UserType | null>("internal"); const [selectedOption, setSelectedOption] = useState<string | null>("internal");
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1); const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]); const [idps, setIdps] = useState<IdpOption[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null); const [userOptions, setUserOptions] = useState<UserOption[]>([]);
const [dataLoaded, setDataLoaded] = useState(false); const [dataLoaded, setDataLoaded] = useState(false);
const internalFormSchema = z.object({ const internalFormSchema = z.object({
@ -80,7 +92,13 @@ export default function Page() {
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
}); });
const externalFormSchema = z.object({ const googleAzureFormSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
name: z.string().optional(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
});
const genericOidcFormSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }), username: z.string().min(1, { message: t("usernameRequired") }),
email: z email: z
.string() .string()
@ -88,19 +106,51 @@ export default function Page() {
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
name: z.string().optional(), name: z.string().optional(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
idpId: z.string().min(1, { message: t("idpSelectPlease") })
}); });
const formatIdpType = (type: string) => { const formatIdpType = (type: string) => {
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case "oidc": case "oidc":
return t("idpGenericOidc"); return t("idpGenericOidc");
case "google":
return t("idpGoogleDescription");
case "azure":
return t("idpAzureDescription");
default: default:
return type; return type;
} }
}; };
const getIdpIcon = (variant: string | null) => {
if (!variant) return null;
switch (variant.toLowerCase()) {
case "google":
return (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
);
case "azure":
return (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
);
default:
return null;
}
};
const validFor = [ const validFor = [
{ hours: 24, name: t("day", { count: 1 }) }, { hours: 24, name: t("day", { count: 1 }) },
{ hours: 48, name: t("day", { count: 2 }) }, { hours: 48, name: t("day", { count: 2 }) },
@ -120,45 +170,39 @@ export default function Page() {
} }
}); });
const externalForm = useForm<z.infer<typeof externalFormSchema>>({ const googleAzureForm = useForm<z.infer<typeof googleAzureFormSchema>>({
resolver: zodResolver(externalFormSchema), resolver: zodResolver(googleAzureFormSchema),
defaultValues: {
email: "",
name: "",
roleId: ""
}
});
const genericOidcForm = useForm<z.infer<typeof genericOidcFormSchema>>({
resolver: zodResolver(genericOidcFormSchema),
defaultValues: { defaultValues: {
username: "", username: "",
email: "", email: "",
name: "", name: "",
roleId: "", roleId: ""
idpId: ""
} }
}); });
useEffect(() => { useEffect(() => {
if (userType === "internal") { if (selectedOption === "internal") {
setSendEmail(env.email.emailEnabled); setSendEmail(env.email.emailEnabled);
internalForm.reset(); internalForm.reset();
setInviteLink(null); setInviteLink(null);
setExpiresInDays(1); setExpiresInDays(1);
} else if (userType === "oidc") { } else if (selectedOption && selectedOption !== "internal") {
externalForm.reset(); googleAzureForm.reset();
genericOidcForm.reset();
} }
}, [userType, env.email.emailEnabled, internalForm, externalForm]); }, [selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm]);
const [userTypes, setUserTypes] = useState<StrategyOption<string>[]>([
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription"),
disabled: false
},
{
id: "oidc",
title: t("userTypeExternal"),
description: t("userTypeExternalDescription"),
disabled: true
}
]);
useEffect(() => { useEffect(() => {
if (!userType) { if (!selectedOption) {
return; return;
} }
@ -199,20 +243,6 @@ export default function Page() {
if (res?.status === 200) { if (res?.status === 200) {
setIdps(res.data.data.idps); setIdps(res.data.data.idps);
if (res.data.data.idps.length) {
setUserTypes((prev) =>
prev.map((type) => {
if (type.id === "oidc") {
return {
...type,
disabled: false
};
}
return type;
})
);
}
} }
} }
@ -226,6 +256,33 @@ export default function Page() {
fetchInitialData(); fetchInitialData();
}, []); }, []);
// Build user options when IDPs are loaded
useEffect(() => {
const options: UserOption[] = [
{
id: "internal",
title: t("userTypeInternal"),
description: t("userTypeInternalDescription"),
disabled: false
}
];
// Add IDP options
idps.forEach((idp) => {
options.push({
id: `idp-${idp.idpId}`,
title: idp.name,
description: formatIdpType(idp.variant || idp.type),
disabled: false,
icon: getIdpIcon(idp.variant),
idpId: idp.idpId,
variant: idp.variant
});
});
setUserOptions(options);
}, [idps, t]);
async function onSubmitInternal( async function onSubmitInternal(
values: z.infer<typeof internalFormSchema> values: z.infer<typeof internalFormSchema>
) { ) {
@ -274,9 +331,52 @@ export default function Page() {
setLoading(false); setLoading(false);
} }
async function onSubmitExternal( async function onSubmitGoogleAzure(
values: z.infer<typeof externalFormSchema> values: z.infer<typeof googleAzureFormSchema>
) { ) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
if (!selectedUserOption?.idpId) return;
setLoading(true);
const res = await api
.put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure
email: values.email,
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId)
})
.catch((e) => {
toast({
variant: "destructive",
title: t("userErrorCreate"),
description: formatAxiosError(
e,
t("userErrorCreateDescription")
)
});
});
if (res && res.status === 201) {
toast({
variant: "default",
title: t("userCreated"),
description: t("userCreatedDescription")
});
router.push(`/${orgId}/settings/access/users`);
}
setLoading(false);
}
async function onSubmitGenericOidc(
values: z.infer<typeof genericOidcFormSchema>
) {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
if (!selectedUserOption?.idpId) return;
setLoading(true); setLoading(true);
const res = await api const res = await api
@ -285,7 +385,7 @@ export default function Page() {
email: values.email, email: values.email,
name: values.name, name: values.name,
type: "oidc", type: "oidc",
idpId: parseInt(values.idpId), idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId) roleId: parseInt(values.roleId)
}) })
.catch((e) => { .catch((e) => {
@ -330,7 +430,7 @@ export default function Page() {
<div> <div>
<SettingsContainer> <SettingsContainer>
{!inviteLink && build !== "saas" ? ( {!inviteLink && build !== "saas" && dataLoaded ? (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@ -342,15 +442,15 @@ export default function Page() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<StrategySelect <StrategySelect
options={userTypes} options={userOptions}
defaultValue={userType || undefined} defaultValue={selectedOption || undefined}
onChange={(value) => { onChange={(value) => {
setUserType(value as UserType); setSelectedOption(value);
if (value === "internal") { if (value === "internal") {
internalForm.reset(); internalForm.reset();
} else if (value === "oidc") { } else {
externalForm.reset(); googleAzureForm.reset();
setSelectedIdp(null); genericOidcForm.reset();
} }
}} }}
cols={2} cols={2}
@ -359,7 +459,7 @@ export default function Page() {
</SettingsSection> </SettingsSection>
) : null} ) : null}
{userType === "internal" && dataLoaded && ( {selectedOption === "internal" && dataLoaded && (
<> <>
{!inviteLink ? ( {!inviteLink ? (
<SettingsSection> <SettingsSection>
@ -564,71 +664,7 @@ export default function Page() {
</> </>
)} )}
{userType !== "internal" && dataLoaded && ( {selectedOption && selectedOption !== "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpSelect")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
{t("idpNotConfigured")}
</p>
) : (
<Form {...externalForm}>
<FormField
control={externalForm.control}
name="idpId"
render={({ field }) => (
<FormItem>
<StrategySelect
options={idps.map(
(idp) => ({
id: idp.idpId.toString(),
title: idp.name,
description:
formatIdpType(
idp.type
)
})
)}
defaultValue={
field.value
}
onChange={(
value
) => {
field.onChange(
value
);
const idp =
idps.find(
(idp) =>
idp.idpId.toString() ===
value
);
setSelectedIdp(
idp || null
);
}}
cols={2}
/>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
</SettingsSectionBody>
</SettingsSection>
{idps.length > 0 && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@ -640,144 +676,206 @@ export default function Page() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...externalForm}> {/* Google/Azure Form */}
<form {(() => {
onSubmit={externalForm.handleSubmit( const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
onSubmitExternal return selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure";
)} })() && (
className="space-y-4" <Form {...googleAzureForm}>
id="create-user-form" <form
> onSubmit={googleAzureForm.handleSubmit(
<FormField onSubmitGoogleAzure
control={
externalForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"username"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t(
"usernameUniq"
)}
</p>
<FormMessage />
</FormItem>
)} )}
/> className="space-y-4"
id="create-user-form"
<FormField >
control={ <FormField
externalForm.control control={googleAzureForm.control}
} name="email"
name="email" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>
<FormLabel> {t("email")}
{t( </FormLabel>
"emailOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"nameOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <Input
<SelectValue {...field}
placeholder={t( />
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <FormMessage />
{roles.map( </FormItem>
( )}
role />
) => (
<FormField
control={googleAzureForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("nameOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={googleAzureForm.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("accessRoleSelect")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem <SelectItem
key={ key={role.roleId}
role.roleId
}
value={role.roleId.toString()} value={role.roleId.toString()}
> >
{ {role.name}
role.name
}
</SelectItem> </SelectItem>
) ))}
)} </SelectContent>
</SelectContent> </Select>
</Select> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
/>
</form>
</Form>
)}
{/* Generic OIDC Form */}
{(() => {
const selectedUserOption = userOptions.find(opt => opt.id === selectedOption);
return selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure";
})() && (
<Form {...genericOidcForm}>
<form
onSubmit={genericOidcForm.handleSubmit(
onSubmitGenericOidc
)} )}
/> className="space-y-4"
</form> id="create-user-form"
</Form> >
<FormField
control={genericOidcForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
{t("usernameUniq")}
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("emailOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("nameOptional")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={genericOidcForm.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("accessRoleSelect")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)}
</>
)} )}
</SettingsContainer> </SettingsContainer>
<div className="flex justify-end space-x-2 mt-8"> <div className="flex justify-end space-x-2 mt-8">
{userType && dataLoaded && ( {selectedOption && dataLoaded && (
<Button <Button
type={inviteLink ? "button" : "submit"} type={inviteLink ? "button" : "submit"}
form={inviteLink ? undefined : "create-user-form"} form={inviteLink ? undefined : "create-user-form"}

View file

@ -2,13 +2,13 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { ListUsersResponse } from "@server/routers/user"; import { ListUsersResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import UsersTable, { UserRow } from "./UsersTable"; import UsersTable, { UserRow } from "../../../../../components/UsersTable";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react"; import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
@ -77,6 +77,7 @@ export default async function UsersPage(props: UsersPageProps) {
name: user.name, name: user.name,
email: user.email, email: user.email,
type: user.type, type: user.type,
idpVariant: user.idpVariant,
idpId: user.idpId, idpId: user.idpId,
idpName: user.idpName || t('idpNameInternal'), idpName: user.idpName || t('idpNameInternal'),
status: t('userConfirmed'), status: t('userConfirmed'),

View file

@ -210,6 +210,11 @@ export default function Page() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4" className="space-y-4"
id="create-site-form" id="create-site-form"
> >

View file

@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable"; import OrgApiKeysTable, { OrgApiKeyRow } from "../../../../components/OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
@ -15,7 +15,7 @@ export const dynamic = "force-dynamic";
export default async function ApiKeysPage(props: ApiKeyPageProps) { export default async function ApiKeysPage(props: ApiKeyPageProps) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = []; let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
try { try {
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>( const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(

View file

@ -3,14 +3,14 @@ import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetClientResponse } from "@server/routers/client"; import { GetClientResponse } from "@server/routers/client";
import ClientInfoCard from "./ClientInfoCard"; import ClientInfoCard from "../../../../../components/ClientInfoCard";
import ClientProvider from "@app/providers/ClientProvider"; import ClientProvider from "@app/providers/ClientProvider";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";
type SettingsLayoutProps = { type SettingsLayoutProps = {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ clientId: number; orgId: string }>; params: Promise<{ clientId: number | string; orgId: string }>;
} }
export default async function SettingsLayout(props: SettingsLayoutProps) { export default async function SettingsLayout(props: SettingsLayoutProps) {

View file

@ -1,7 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function ClientPage(props: { export default async function ClientPage(props: {
params: Promise<{ orgId: string; clientId: number }>; params: Promise<{ orgId: string; clientId: number | string }>;
}) { }) {
const params = await props.params; const params = await props.params;
redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`); redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`);

View file

@ -42,6 +42,10 @@ import {
FaFreebsd, FaFreebsd,
FaWindows FaWindows
} from "react-icons/fa"; } from "react-icons/fa";
import {
SiNixos,
SiKubernetes
} from "react-icons/si";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
@ -248,10 +252,14 @@ export default function Page() {
return <FaApple className="h-4 w-4 mr-2" />; return <FaApple className="h-4 w-4 mr-2" />;
case "docker": case "docker":
return <FaDocker className="h-4 w-4 mr-2" />; return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes":
return <SiKubernetes className="h-4 w-4 mr-2" />;
case "podman": case "podman":
return <FaCubes className="h-4 w-4 mr-2" />; return <FaCubes className="h-4 w-4 mr-2" />;
case "freebsd": case "freebsd":
return <FaFreebsd className="h-4 w-4 mr-2" />; return <FaFreebsd className="h-4 w-4 mr-2" />;
case "nixos":
return <SiNixos className="h-4 w-4 mr-2" />;
default: default:
return <Terminal className="h-4 w-4 mr-2" />; return <Terminal className="h-4 w-4 mr-2" />;
} }
@ -440,6 +448,11 @@ export default function Page() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4" className="space-y-4"
id="create-client-form" id="create-client-form"
> >

View file

@ -1,10 +1,10 @@
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ClientRow } from "./ClientsTable"; import { ClientRow } from "../../../../components/ClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client"; import { ListClientsResponse } from "@server/routers/client";
import ClientsTable from "./ClientsTable"; import ClientsTable from "../../../../components/ClientsTable";
type ClientsPageProps = { type ClientsPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;

Some files were not shown because too many files have changed in this diff Show more