Compare commits

..

172 Commits
1.18.2 ... main

Author SHA1 Message Date
Owen Schwartz
10f95896aa Merge pull request #3030 from fosrl/dev
1.18.3-s.2 fix
2026-05-07 20:08:05 -07:00
Owen
5b8994d143 Cange to use primaryDb 2026-05-07 20:07:06 -07:00
Owen
c46ef2fe9c Fix ts type issue 2026-05-07 20:03:48 -07:00
Owen Schwartz
4cd025dd91 Merge pull request #3029 from fosrl/dev
1.18.3-s.2
2026-05-07 17:44:35 -07:00
Owen
ce04ea9720 Fix not including today
Fixes #3028
2026-05-07 16:15:13 -07:00
Owen
a3ce382725 Pick up other domains in the sans field 2026-05-07 15:49:12 -07:00
Owen
4eb49e3e60 Make the rebuild long running function background 2026-05-07 15:40:34 -07:00
Owen
2a9481023a Dont show link when wildcard 2026-05-07 15:15:03 -07:00
Owen
8ed01372b8 Add org to logs 2026-05-07 15:14:44 -07:00
Owen Schwartz
6a7d4fd385 Merge pull request #3021 from fosrl/dev
If not exists on trial table
2026-05-06 20:00:55 -07:00
Owen
7bc08c0425 If not exists on trial table 2026-05-06 20:00:23 -07:00
Owen Schwartz
36a47c4cfb Merge pull request #3015 from fosrl/dev
Dev
2026-05-06 16:59:02 -07:00
Owen
7dce4500ec Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-06 16:58:39 -07:00
Owen
72e48a56df Remove explicit call 2026-05-06 16:58:28 -07:00
Owen Schwartz
293d9865b4 Merge pull request #3014 from fosrl/dev
1.18.3
2026-05-06 16:30:36 -07:00
Owen Schwartz
45a2a07747 Merge pull request #3012 from fosrl/crowdin_dev
New Crowdin updates
2026-05-06 16:21:45 -07:00
Owen Schwartz
181bcffe7d New translations en-us.json (Spanish)
[ci skip]
2026-05-06 16:17:46 -07:00
Owen Schwartz
ed35d25598 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-06 16:17:44 -07:00
Owen Schwartz
05e738e0f4 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-06 16:17:42 -07:00
Owen Schwartz
c95e66d531 New translations en-us.json (Turkish)
[ci skip]
2026-05-06 16:17:40 -07:00
Owen Schwartz
cc2a416a92 New translations en-us.json (Russian)
[ci skip]
2026-05-06 16:17:39 -07:00
Owen Schwartz
70bb42f1fc New translations en-us.json (Portuguese)
[ci skip]
2026-05-06 16:17:37 -07:00
Owen Schwartz
10d2bc1e9e New translations en-us.json (Polish)
[ci skip]
2026-05-06 16:17:35 -07:00
Owen Schwartz
385f57ec93 New translations en-us.json (Dutch)
[ci skip]
2026-05-06 16:17:33 -07:00
Owen Schwartz
9c8ffdb661 New translations en-us.json (Korean)
[ci skip]
2026-05-06 16:17:32 -07:00
Owen Schwartz
5a5feccc76 New translations en-us.json (Italian)
[ci skip]
2026-05-06 16:17:30 -07:00
Owen Schwartz
36e7054386 New translations en-us.json (German)
[ci skip]
2026-05-06 16:17:28 -07:00
Owen Schwartz
19de12b12e New translations en-us.json (Czech)
[ci skip]
2026-05-06 16:17:26 -07:00
Owen Schwartz
d96e930679 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-06 16:17:25 -07:00
Owen Schwartz
5e51b8ad74 New translations en-us.json (French)
[ci skip]
2026-05-06 16:17:23 -07:00
Owen Schwartz
885b9e638d New translations en-us.json (Spanish)
[ci skip]
2026-05-06 16:15:56 -07:00
Owen Schwartz
56ef3a934a New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-06 16:15:55 -07:00
Owen Schwartz
98bc199c8e New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-06 16:15:53 -07:00
Owen Schwartz
0444d3490b New translations en-us.json (Turkish)
[ci skip]
2026-05-06 16:15:51 -07:00
Owen Schwartz
54820d1db0 New translations en-us.json (Russian)
[ci skip]
2026-05-06 16:15:49 -07:00
Owen Schwartz
961cbfcacc New translations en-us.json (Portuguese)
[ci skip]
2026-05-06 16:15:47 -07:00
Owen Schwartz
a784cd307e New translations en-us.json (Polish)
[ci skip]
2026-05-06 16:15:46 -07:00
Owen Schwartz
b46c948522 New translations en-us.json (Dutch)
[ci skip]
2026-05-06 16:15:44 -07:00
Owen Schwartz
7eab2cc0bb New translations en-us.json (Korean)
[ci skip]
2026-05-06 16:15:42 -07:00
Owen Schwartz
5ff2569ece New translations en-us.json (Italian)
[ci skip]
2026-05-06 16:15:40 -07:00
Owen Schwartz
c59505be8d New translations en-us.json (German)
[ci skip]
2026-05-06 16:15:38 -07:00
Owen Schwartz
2b0e6649fa New translations en-us.json (Czech)
[ci skip]
2026-05-06 16:15:37 -07:00
Owen Schwartz
428e9b546e New translations en-us.json (Bulgarian)
[ci skip]
2026-05-06 16:15:35 -07:00
Owen Schwartz
5089660381 New translations en-us.json (French)
[ci skip]
2026-05-06 16:15:33 -07:00
Owen
998364b09d Properly respect flags.disableEnterpriseFeatures 2026-05-06 16:13:07 -07:00
Owen
ac0d88d9b7 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-06 16:02:31 -07:00
Owen Schwartz
401f04b53e Merge pull request #3011 from fosrl/copilot/fix-create-alert-visibility
Hide alerting UI when disable_enterprise_features is true
2026-05-06 16:02:22 -07:00
Owen
b046ab7513 Add locks to allocations 2026-05-06 15:58:51 -07:00
Owen
65ee9b9544 Add transaction to alias address picking 2026-05-06 15:53:46 -07:00
Owen
49c7319342 Format and make the error a warning 2026-05-06 15:51:05 -07:00
Owen
ce7df5ddaa Update log message 2026-05-06 15:19:13 -07:00
Owen
af1739fbcb Bump version 2026-05-06 15:15:03 -07:00
Owen
f01c9ee41c Try to fix time issue
Fixes #3007
2026-05-06 14:45:18 -07:00
Owen
19f8956218 Support flattened data fields 2026-05-06 14:30:57 -07:00
Owen
a8c50b8618 Add clear certificates pangctl command 2026-05-06 14:08:28 -07:00
Owen
e86a381ed5 Fix the input to be tags 2026-05-06 14:05:18 -07:00
Owen
dd18375f23 Fix org selectors 2026-05-06 13:57:17 -07:00
Owen Schwartz
46b72b9e8c New translations en-us.json (Spanish)
[ci skip]
2026-05-06 11:14:54 -07:00
Owen Schwartz
7bb2a5a0a5 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-06 11:14:52 -07:00
Owen Schwartz
4b777b1488 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-06 11:14:50 -07:00
Owen Schwartz
428f91b5fa New translations en-us.json (Turkish)
[ci skip]
2026-05-06 11:14:48 -07:00
Owen Schwartz
caaae77f74 New translations en-us.json (Russian)
[ci skip]
2026-05-06 11:14:46 -07:00
Owen Schwartz
4df27b316c New translations en-us.json (Portuguese)
[ci skip]
2026-05-06 11:14:43 -07:00
Owen Schwartz
8f52a48937 New translations en-us.json (Polish)
[ci skip]
2026-05-06 11:14:41 -07:00
Owen Schwartz
a53da85fb4 New translations en-us.json (Dutch)
[ci skip]
2026-05-06 11:14:39 -07:00
Owen Schwartz
08a5785cc5 New translations en-us.json (Korean)
[ci skip]
2026-05-06 11:14:37 -07:00
Owen Schwartz
ff928b846d New translations en-us.json (Italian)
[ci skip]
2026-05-06 11:14:35 -07:00
Owen Schwartz
47b3d26d0e New translations en-us.json (German)
[ci skip]
2026-05-06 11:14:32 -07:00
Owen Schwartz
6270dce86a New translations en-us.json (Czech)
[ci skip]
2026-05-06 11:14:30 -07:00
Owen Schwartz
864d1d5cc4 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-06 11:14:28 -07:00
Owen Schwartz
b63eda64f4 New translations en-us.json (French)
[ci skip]
2026-05-06 11:14:26 -07:00
Owen Schwartz
b8e942478d New translations en-us.json (Spanish)
[ci skip]
2026-05-06 11:09:41 -07:00
Owen Schwartz
6d9bfbf08f New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-06 11:09:39 -07:00
Owen Schwartz
35ce947e19 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-06 11:09:37 -07:00
Owen Schwartz
b17ba96235 New translations en-us.json (Turkish)
[ci skip]
2026-05-06 11:09:35 -07:00
Owen Schwartz
f1bdb25497 New translations en-us.json (Russian)
[ci skip]
2026-05-06 11:09:33 -07:00
Owen Schwartz
e11527b430 New translations en-us.json (Portuguese)
[ci skip]
2026-05-06 11:09:31 -07:00
Owen Schwartz
31d3b314e9 New translations en-us.json (Polish)
[ci skip]
2026-05-06 11:09:29 -07:00
Owen Schwartz
3bce57c65c New translations en-us.json (Dutch)
[ci skip]
2026-05-06 11:09:27 -07:00
Owen Schwartz
d649a83535 New translations en-us.json (Korean)
[ci skip]
2026-05-06 11:09:25 -07:00
Owen Schwartz
3c6b1781bc New translations en-us.json (Italian)
[ci skip]
2026-05-06 11:09:23 -07:00
Owen Schwartz
7dd50f65fc New translations en-us.json (German)
[ci skip]
2026-05-06 11:09:20 -07:00
Owen Schwartz
342b4aeddf New translations en-us.json (Czech)
[ci skip]
2026-05-06 11:09:18 -07:00
Owen Schwartz
65908fa00f New translations en-us.json (Bulgarian)
[ci skip]
2026-05-06 11:09:16 -07:00
Owen Schwartz
223e0d0706 New translations en-us.json (French)
[ci skip]
2026-05-06 11:09:14 -07:00
Owen
5426031cd4 Remove duplicate ssl toggle 2026-05-06 11:05:08 -07:00
Owen
adf4a1ffda Link to http private resources 2026-05-06 11:03:38 -07:00
Owen
780feba19c Translate the member page 2026-05-06 10:26:20 -07:00
copilot-swe-agent[bot]
3ac315b52e Fix useEffect dependency array in create alert page
Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/4337e8e4-2110-45ae-bbf9-63f273d2a9a3

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-06 16:55:38 +00:00
copilot-swe-agent[bot]
1b183d32c0 Hide alerting features when disable_enterprise_features is set
Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/4337e8e4-2110-45ae-bbf9-63f273d2a9a3

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-06 16:54:58 +00:00
copilot-swe-agent[bot]
0c643e91a6 Initial plan 2026-05-06 16:52:05 +00:00
Owen
fab53ba26a Dont show the link if not the org owner 2026-05-05 20:40:59 -07:00
Owen
62e19a2f4e Remove the hover effect 2026-05-05 20:10:14 -07:00
Owen
7d67fb9984 Make sure the domain is defined on a http resource 2026-05-05 20:07:06 -07:00
Owen Schwartz
7436aebca7 Merge pull request #2893 from Fredkiss3/feat/roles-and-user-multi-selectors
feat: roles & users selector
2026-05-05 17:36:40 -07:00
miloschwartz
66fda553e4 introduce caching in calculate func 2026-05-05 14:12:02 -07:00
Owen Schwartz
432dc81875 Merge pull request #3006 from fosrl/dev
don't await second calculate func
2026-05-05 13:46:05 -07:00
miloschwartz
2ecf076c0f don't await second calculate func 2026-05-05 12:37:52 -07:00
Owen Schwartz
9b71c426c7 Merge pull request #3005 from fosrl/dev
1.18.2-s.4
2026-05-05 12:12:09 -07:00
miloschwartz
e06dda27cb dont wait rebuild 2026-05-05 12:10:55 -07:00
miloschwartz
18f6e0f75d add subscribed check back 2026-05-05 11:52:31 -07:00
miloschwartz
3b232bcc58 set orgId to undefined 2026-05-05 11:31:58 -07:00
Owen
c575bb76e7 Fix only using acme.json in dir
Ref #2978
2026-05-05 11:11:43 -07:00
Owen Schwartz
87e6c7ba36 Merge pull request #3003 from fosrl/dev
1.18.2-s.3
2026-05-05 10:54:48 -07:00
Owen
c8e7e0ee1e WAL off default ENABLE_SQLITE_WAL_MODE to enable 2026-05-04 17:54:28 -07:00
Owen Schwartz
0e7aafd364 Merge pull request #2998 from Josh-Voyles/mem-fix-2
fix: deterministically finalize SQLite prepared statements to prevent native memory leak (#2120)
2026-05-04 17:29:45 -07:00
miloschwartz
91f1bae3e9 fix alignement in info sections 2026-05-04 14:51:17 -07:00
miloschwartz
53c138ce3e use consistent button spacing 2026-05-04 14:34:32 -07:00
miloschwartz
969db14a3c remove delay in oidc validate 2026-05-04 13:14:35 -07:00
Fred KISSIE
1ca1059673 ♻️ 10 users/roles per page 2026-05-04 20:59:46 +02:00
Owen Schwartz
9410a18404 Merge pull request #2997 from fosrl/dev
Translations
2026-05-04 11:49:26 -07:00
Owen Schwartz
c1c387bdd8 Merge pull request #2996 from fosrl/crowdin_dev
New Crowdin updates
2026-05-04 11:48:58 -07:00
Owen Schwartz
6e83d77a87 New translations en-us.json (Spanish)
[ci skip]
2026-05-04 11:48:00 -07:00
Owen Schwartz
ba9a1efa4c New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-04 11:47:58 -07:00
Owen Schwartz
9e046b9608 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-04 11:47:56 -07:00
Owen Schwartz
37794eb299 New translations en-us.json (Turkish)
[ci skip]
2026-05-04 11:47:55 -07:00
Owen Schwartz
4e66b0e74b New translations en-us.json (Russian)
[ci skip]
2026-05-04 11:47:53 -07:00
Owen Schwartz
44fa873977 New translations en-us.json (Portuguese)
[ci skip]
2026-05-04 11:47:51 -07:00
Owen Schwartz
505461a533 New translations en-us.json (Polish)
[ci skip]
2026-05-04 11:47:49 -07:00
Owen Schwartz
a88c5b1428 New translations en-us.json (Dutch)
[ci skip]
2026-05-04 11:47:47 -07:00
Owen Schwartz
97ef1d605c New translations en-us.json (Korean)
[ci skip]
2026-05-04 11:47:45 -07:00
Owen Schwartz
3fc1c9d948 New translations en-us.json (Italian)
[ci skip]
2026-05-04 11:47:44 -07:00
Owen Schwartz
68bd37ab6c New translations en-us.json (German)
[ci skip]
2026-05-04 11:47:42 -07:00
Owen Schwartz
5c317c535b New translations en-us.json (Czech)
[ci skip]
2026-05-04 11:47:40 -07:00
Owen Schwartz
37c6b11899 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-04 11:47:38 -07:00
Owen Schwartz
45c567ffa0 New translations en-us.json (French)
[ci skip]
2026-05-04 11:47:36 -07:00
Fred KISSIE
49d22498fc ♻️ only select one role in CE and if user is non paying 2026-05-04 20:47:00 +02:00
Owen Schwartz
23f4302186 Merge pull request #2995 from fosrl/dev
1.18.2-s.2
2026-05-04 11:43:24 -07:00
Owen Schwartz
775ea64b55 Merge pull request #2977 from fosrl/newt-install-commands
fix(newt): update Helm install credentials and client flag handling
2026-05-04 11:40:19 -07:00
Owen
64ad7641af Add migration
Fixes #2968
Fixes #2990
2026-05-04 11:35:07 -07:00
Owen
d724f5bb5d Add missing redirects and threshold to api
Fixes #2987
2026-05-04 10:46:11 -07:00
Fred KISSIE
30e627cca8 Merge branch 'dev' into feat/roles-and-user-multi-selectors 2026-05-04 18:49:19 +02:00
Fred KISSIE
53c1e2e742 ♻️ refactor 2026-05-04 18:45:31 +02:00
Owen Schwartz
fb4bda077b Merge pull request #2983 from fosrl/dev
1.18.2-s.1
2026-05-03 14:59:12 -07:00
Owen
d4f7c4a9c4 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-03 14:46:59 -07:00
miloschwartz
1cc0e9b689 consolidate org idps in login form 2026-05-03 14:46:48 -07:00
Owen
584be4dbd2 Add badge 2026-05-03 14:45:42 -07:00
Owen
c33e295ce7 Add a banner showing that you are on a trial 2026-05-03 14:42:43 -07:00
Owen
1a926a7127 Handle trial limit lifecycle 2026-05-03 14:31:05 -07:00
miloschwartz
eb515a8f7f consolidate orgidps in import list 2026-05-03 14:16:36 -07:00
Owen
81b8a8a9e3 Fix ns cert generation 2026-05-03 12:29:48 -07:00
Owen
bcd164219f Try to speed up 2026-05-03 12:29:48 -07:00
Owen Schwartz
c90e405105 Merge pull request #2843 from Blacks-Army/dev
Exclude local/private/CGNAT IPs from geo-block rules (fixes  issue #2239)
2026-05-03 11:19:36 -07:00
Mustafa
b2c8311b26 Merge branch 'fosrl:dev' into dev 2026-05-03 18:53:48 +02:00
Josh Voyles
2154811ffb removed possible introduced HA Redis bug; improved comment 2026-05-03 09:39:27 -04:00
Marc Schäfer
1772ac220f fix(newt): update Helm install credentials and client flag handling
Use a Kubernetes Secret for Newt Helm chart credentials and configure the chart
with auth.existingSecretName instead of passing credential values through
auth.keys.*.

Add Helm-specific acceptClients handling so the generated Kubernetes command sets
newtInstances[0].acceptClients=true when client connections are enabled.
2026-05-03 15:07:42 +02:00
Josh Voyles
9bd33072f4 cleaned comments - more concise 2026-05-03 00:00:11 -04:00
Josh Voyles
0655ba9423 fix: revert investigative changes, keep root cause fixes only
Reverts diagnostic instrumentation and defensive hardening added during
memory leak investigation. Only root cause fixes survive.

Root causes fixed:
- SQLite driver: auto-finalize wrapper + PRAGMAs
- WS routers: delete clientConfigVersions on disconnect (unbounded Map leak)
- WS private router: same + Redis key cleanup

Reverted:
- Memory monitor, rate limiting, request timeouts (diagnostic/hardening)
- shutdownAuditLogger wiring, audit re-queue change, debug logs (cleanup/secondary)
- package-lock.json drift
2026-05-02 16:33:13 -04:00
Josh Voyles
2c85bcd06b fix(db): deterministically finalize prepared statements after execution
Wrap Statement .all()/.get()/.run() via autoFinalizeStatement() with
try/finally calling stmt.finalize() post-execution, releasing native
sqlite3_stmt memory immediately instead of waiting for GC.

Safe because:
- Drizzle one-time queries invoke each statement once only
- Drizzle does not access statement after .all()/.get()/.run() returns
- Migration scripts use isolated new Database() instances (unpatched)
- No app code holds persistent .prepare() refs on main db
2026-05-02 15:50:54 -04:00
Josh Voyles
d6abe83fdc fix: memory improvements
- SQLite: enable WAL mode and PRAGMA performance settings

- ws.ts (public + private): fix clientConfigVersions memory leak

- internal server: add rate limiting and request timeouts

- audit log: fix flush re-queue feedback loop

- memory: add monitoring instrumentation

- security: remove debug log of full request body
2026-05-02 07:37:18 -04:00
Fred KISSIE
657072dd17 💄 fix input styles for tags 2026-04-30 22:06:36 +02:00
Fred KISSIE
443a19165f ♻️ refactor 2026-04-30 22:02:23 +02:00
Fred KISSIE
b4906ec9ba ♻️ replace roles tag with roles selector in role config fields 2026-04-30 22:01:46 +02:00
Fred KISSIE
39bf64bc35 Merge branch 'dev' into feat/roles-and-user-multi-selectors 2026-04-30 16:55:25 +02:00
Fred KISSIE
a3f30eff02 ♻️ remove unused code and imports 2026-04-29 07:29:20 +02:00
Fred KISSIE
081940dff8 replace roles & users in uptime alert section 2026-04-29 07:29:05 +02:00
Fred KISSIE
c4cf4cdec4 ♻️ show idp name in user selector 2026-04-29 06:57:49 +02:00
Fred KISSIE
85f2165a1e ♻️ refactor multi select components 2026-04-29 05:19:36 +02:00
Fred KISSIE
1bc7175dd4 replace user select in resource auth and alert rule field 2026-04-29 05:19:23 +02:00
Fred KISSIE
ddaa9c32a7 ♻️ replace roles & user selectors in machines & create user 2026-04-28 05:08:20 +02:00
Fred KISSIE
27b2ec309d 🚧 users selector 2026-04-25 06:18:13 +02:00
Fred KISSIE
91ce8bea4b 🔨 add local mailer for catching emails 2026-04-25 05:59:43 +02:00
Fred KISSIE
2ea9d27237 machine selector 2026-04-25 05:26:41 +02:00
Fred KISSIE
95cbaaae21 new multi select tag input 2026-04-25 04:47:31 +02:00
Fred KISSIE
955aa41f53 revert changes modifying existing tag input 2026-04-25 04:47:17 +02:00
Fred KISSIE
cb3fa028c3 ♻️ create custom autocomplete tag input 2026-04-25 04:10:54 +02:00
Fred KISSIE
c746e1bc8d 🚧 wip 2026-04-24 08:33:43 +02:00
Fred KISSIE
da4dd88fdd Merge branch 'dev' into feat/roles-and-user-multi-selectors 2026-04-24 00:40:17 +02:00
Fred KISSIE
b9bee2836b 🚧 wip 2026-04-23 06:33:57 +02:00
Fred KISSIE
53c48e6f04 🌐 update french translations 2026-04-23 05:17:33 +02:00
Fred KISSIE
9db5ff9ff7 ♻️ small refactor 2026-04-23 04:22:18 +02:00
Mustafa
8e1905a695 Exclude local/private/CGNAT IPs from COUNTRY=ALL and ASN=ALL/AS0 geo-blocking rules 2026-04-12 20:19:32 +02:00
111 changed files with 4508 additions and 2072 deletions

View File

@@ -0,0 +1,28 @@
import { CommandModule } from "yargs";
import { db, certificates } from "@server/db";
type ClearCertificatesArgs = {};
export const clearCertificates: CommandModule<{}, ClearCertificatesArgs> = {
command: "clear-certificates",
describe: "Delete all entries from the certificates table",
builder: (yargs) => {
return yargs;
},
handler: async (argv: {}) => {
try {
console.log("Clearing all certificates from the database...");
const deleted = await db.delete(certificates).returning();
console.log(
`Deleted ${deleted.length} certificate(s) from the database`
);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};

View File

@@ -9,6 +9,7 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient"; import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("pangctl") .scriptName("pangctl")
@@ -19,5 +20,6 @@ yargs(hideBin(process.argv))
.command(clearLicenseKeys) .command(clearLicenseKeys)
.command(deleteClient) .command(deleteClient)
.command(generateOrgCaKeys) .command(generateOrgCaKeys)
.command(clearCertificates)
.demandCommand() .demandCommand()
.help().argv; .help().argv;

View File

@@ -0,0 +1,12 @@
services:
mailer:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
volumes:
- mailpit-storage:/data
environment:
- MP_DATABASE=/data/mailpit.db
volumes:
mailpit-storage:

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.", "subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.",
"trialBannerMessage": "Пробният Ви период изтича след {countdown}. Актуализирайте за запазване на достъпа.", "trialBannerMessage": "Пробният Ви период изтича след {countdown}. Актуализирайте за запазване на достъпа.",
"trialBannerExpired": "Пробният Ви период е изтекъл. Актуализирайте сега, за да възстановите достъпа.", "trialBannerExpired": "Пробният Ви период е изтекъл. Актуализирайте сега, за да възстановите достъпа.",
"billingTrialBannerTitle": "Пробният период е активен",
"billingTrialBannerDescription": "В момента сте в пробен период на бизнес ниво. След края на пробния период, вашият акаунт автоматично ще бъде върнат към функциите и ограниченията на основното ниво. Надградете по всяко време, за да запазите достъпа до текущите функции на плана.",
"billingTrialBannerUpgrade": "Надградете сега",
"billingTrialBadge": "Пробен период",
"trialActive": "Активен пробен период", "trialActive": "Активен пробен период",
"trialExpired": "Пробният период е изтекъл", "trialExpired": "Пробният период е изтекъл",
"trialHasEnded": "Пробният Ви период е приключил.", "trialHasEnded": "Пробният Ви период е приключил.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "Няма валидни методи за удостоверение", "noMoreAuthMethods": "Няма валидни методи за удостоверение",
"ip": "IP", "ip": "IP",
"reason": "Причина", "reason": "Причина",
"requestLogs": "Заявка за логове", "requestLogs": "Логове за HTTP заявки",
"requestAnalytics": "Анализи На Заявки", "requestAnalytics": "Анализи На Заявки",
"host": "Хост", "host": "Хост",
"location": "Местоположение", "location": "Местоположение",
"actionLogs": "Дневници на действията", "actionLogs": "Дневници на действията",
"sidebarLogsRequest": "Заявка за логове", "sidebarLogsRequest": "Логове за HTTP заявки",
"sidebarLogsAccess": "Достъп до логове", "sidebarLogsAccess": "Достъп до логове",
"sidebarLogsAction": "Дневници на действията", "sidebarLogsAction": "Дневници на действията",
"logRetention": "Задържане на логове", "logRetention": "Задържане на логове",
"logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте", "logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте",
"requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация", "requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация",
"requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация", "requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация",
"logRetentionRequestLabel": "Задържане на логове на заявки", "logRetentionRequestLabel": "Задържане на логове за HTTP заявки",
"logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките", "logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките",
"logRetentionAccessLabel": "Задържане на логове за достъп", "logRetentionAccessLabel": "Задържане на логове за достъп",
"logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп", "logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.", "httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.",
"httpDestConnectionLogsTitle": "Логове на връзката", "httpDestConnectionLogsTitle": "Логове на връзката",
"httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.", "httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.",
"httpDestRequestLogsTitle": "Заявки за логове", "httpDestRequestLogsTitle": "Логове за HTTP заявки",
"httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.", "httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.",
"httpDestSaveChanges": "Запази промените", "httpDestSaveChanges": "Запази промените",
"httpDestCreateDestination": "Създаване на дестинация", "httpDestCreateDestination": "Създаване на дестинация",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.", "domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.",
"domainPickerWildcardCertWarningLink": "Научете повече", "domainPickerWildcardCertWarningLink": "Научете повече",
"health": "Здраве", "health": "Здраве",
"domainPendingErrorTitle": "Проблем при проверка" "domainPendingErrorTitle": "Проблем при проверка",
"memberPortalTitle": "Ресурси",
"memberPortalDescription": "Ресурси, до които имате достъп в тази организация",
"memberPortalSortBy": "Сортиране по...",
"memberPortalSortNameAsc": "Име А-Я",
"memberPortalSortNameDesc": "Име Я-А",
"memberPortalSortDomainAsc": "Домен А-Я",
"memberPortalSortDomainDesc": "Домен Я-А",
"memberPortalSortEnabledFirst": "Активирани Първи",
"memberPortalSortDisabledFirst": "Деактивирани Първи",
"memberPortalRefresh": "Обнови",
"memberPortalRefreshResources": "Обнови ресурсите",
"memberPortalFailedToLoad": "Грешка при зареждане на ресурсите",
"memberPortalFailedToLoadDescription": "Грешка при зареждане на ресурсите. Моля, проверете връзката си и опитайте отново.",
"memberPortalUnableToLoad": "Неуспешно зареждане на ресурси",
"memberPortalTryAgain": "Опитай отново",
"memberPortalNoResourcesFound": "Няма намерени ресурси",
"memberPortalNoResourcesAvailable": "Няма налични ресурси",
"memberPortalNoResourcesMatchSearch": "Няма ресурси, съвпадащи с \"{query}\". Опитайте да промените търсените условия или нулирайте търсенето, за да видите всички ресурси.",
"memberPortalNoResourcesAccess": "Още нямате достъп до ресурси. Свържете се с вашия администратор, за да получите достъп до нужните ресурси.",
"memberPortalClearSearch": "Изчисти търсенето",
"memberPortalPublicResources": "Публични ресурси",
"memberPortalPublicResourcesDescription": "Уеб приложения и услуги, достъпни през браузър",
"memberPortalCopiedToClipboard": "Копирано в клипборда",
"memberPortalCopiedUrlDescription": "URL адресът на ресурса е копиран в клипборда.",
"memberPortalOpenResource": "Отвори ресурса",
"memberPortalPrivateResources": "Частни ресурси",
"memberPortalPrivateResourcesDescription": "Ресурси на вътрешната мрежа, достъпни чрез клиент",
"memberPortalResourceDetails": "Детайли за ресурса",
"memberPortalMode": "Режим",
"memberPortalDestination": "Дестинация",
"memberPortalAlias": "Алиас",
"memberPortalCopiedAliasDescription": "Алиасът на ресурса е копиран в клипборда.",
"memberPortalCopiedDestinationDescription": "Дестинацията на ресурса е копирана в клипборда.",
"memberPortalRequiresClientConnection": "Изисква връзка с клиента",
"memberPortalAuthMethods": "Методи на удостоверяване",
"memberPortalSso": "Единно вход (SSO)",
"memberPortalPasswordProtected": "Защитено с парола",
"memberPortalPinCode": "ПИН код",
"memberPortalEmailWhitelist": "Бял списък на имейли",
"memberPortalResourceDisabled": "Ресурсът е деактивиран",
"memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси",
"memberPortalPrevious": "Предишен",
"memberPortalNext": "Следващ"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.", "subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.",
"trialBannerMessage": "Vaše zkušební verze vyprší za {countdown}. Pro udržení přístupu upgraduje.", "trialBannerMessage": "Vaše zkušební verze vyprší za {countdown}. Pro udržení přístupu upgraduje.",
"trialBannerExpired": "Vaše zkušební verze vypršela. Upgradujte nyní pro obnovu přístupu.", "trialBannerExpired": "Vaše zkušební verze vypršela. Upgradujte nyní pro obnovu přístupu.",
"billingTrialBannerTitle": "Aktivní zkušební verze",
"billingTrialBannerDescription": "Právě používáte zkušební verzi na úrovni business. Po skončení zkušební verze se váš účet automaticky vrátí k funkcím a limitům úrovně Basic. Upgradujte kdykoli pro zachování přístupu k funkcím vašeho aktuálního plánu.",
"billingTrialBannerUpgrade": "Upgradovat nyní",
"billingTrialBadge": "Zkušební verze",
"trialActive": "Zkušební verze je aktivní", "trialActive": "Zkušební verze je aktivní",
"trialExpired": "Zkušební verze vypršela", "trialExpired": "Zkušební verze vypršela",
"trialHasEnded": "Vaše zkušební verze skončila.", "trialHasEnded": "Vaše zkušební verze skončila.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP adresa", "ip": "IP adresa",
"reason": "Důvod", "reason": "Důvod",
"requestLogs": "Záznamy požadavků", "requestLogs": "Záznamy HTTP požadavků",
"requestAnalytics": "Vyžádat analýzu", "requestAnalytics": "Vyžádat analýzu",
"host": "Hostitel", "host": "Hostitel",
"location": "Poloha", "location": "Poloha",
"actionLogs": "Záznamy akcí", "actionLogs": "Záznamy akcí",
"sidebarLogsRequest": "Záznamy požadavků", "sidebarLogsRequest": "Záznamy HTTP požadavků",
"sidebarLogsAccess": "Protokoly přístupu", "sidebarLogsAccess": "Protokoly přístupu",
"sidebarLogsAction": "Záznamy akcí", "sidebarLogsAction": "Záznamy akcí",
"logRetention": "Zaznamenávání záznamu", "logRetention": "Zaznamenávání záznamu",
"logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat", "logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat",
"requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci", "requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci",
"requestAnalyticsDescription": "Zobrazit podrobnou analýzu požadavků pro zdroje v této organizaci", "requestAnalyticsDescription": "Zobrazit podrobnou analýzu požadavků pro zdroje v této organizaci",
"logRetentionRequestLabel": "Zachování logu žádosti", "logRetentionRequestLabel": "Zachování logu HTTP požadavků",
"logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků", "logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků",
"logRetentionAccessLabel": "Zachování záznamu přístupu", "logRetentionAccessLabel": "Zachování záznamu přístupu",
"logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy", "logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.", "httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.",
"httpDestConnectionLogsTitle": "Protokoly připojení", "httpDestConnectionLogsTitle": "Protokoly připojení",
"httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.", "httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.",
"httpDestRequestLogsTitle": "Záznamy požadavků", "httpDestRequestLogsTitle": "Záznamy HTTP požadavků",
"httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.", "httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.",
"httpDestSaveChanges": "Uložit změny", "httpDestSaveChanges": "Uložit změny",
"httpDestCreateDestination": "Vytvořit cíl", "httpDestCreateDestination": "Vytvořit cíl",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.", "domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.",
"domainPickerWildcardCertWarningLink": "Zjistit více", "domainPickerWildcardCertWarningLink": "Zjistit více",
"health": "Zdraví", "health": "Zdraví",
"domainPendingErrorTitle": "Problém s ověřením" "domainPendingErrorTitle": "Problém s ověřením",
"memberPortalTitle": "Zdroje",
"memberPortalDescription": "Zdroje, ke kterým máte v této organizaci přístup",
"memberPortalSortBy": "Řadit podle...",
"memberPortalSortNameAsc": "Názvu A-Z",
"memberPortalSortNameDesc": "Názvu Z-A",
"memberPortalSortDomainAsc": "Domény A-Z",
"memberPortalSortDomainDesc": "Domény Z-A",
"memberPortalSortEnabledFirst": "Nejprve povoleno",
"memberPortalSortDisabledFirst": "Nejprve zakázáno",
"memberPortalRefresh": "Aktualizovat",
"memberPortalRefreshResources": "Aktualizovat zdroje",
"memberPortalFailedToLoad": "Nepodařilo se načíst zdroje",
"memberPortalFailedToLoadDescription": "Nepodařilo se načíst zdroje. Zkontrolujte prosím své připojení a zkuste to znovu.",
"memberPortalUnableToLoad": "Nelze načíst zdroje",
"memberPortalTryAgain": "Zkusit znovu",
"memberPortalNoResourcesFound": "Žádné zdroje nebyly nalezeny",
"memberPortalNoResourcesAvailable": "Žádné zdroje nejsou k dispozici",
"memberPortalNoResourcesMatchSearch": "Žádné zdroje neodpovídají \"{query}\". Zkuste přizpůsobit své vyhledávací termíny nebo vyčistit hledání, abyste viděli všechny zdroje.",
"memberPortalNoResourcesAccess": "Zatím nemáte přístup k žádným zdrojům. Kontaktujte svého správce, aby vám poskytl přístup k potřebným zdrojům.",
"memberPortalClearSearch": "Vymazat hledání",
"memberPortalPublicResources": "Veřejné zdroje",
"memberPortalPublicResourcesDescription": "Webové aplikace a služby přístupné přes prohlížeč",
"memberPortalCopiedToClipboard": "Zkopírováno do schránky",
"memberPortalCopiedUrlDescription": "URL zdroje byla zkopírována do vaší schránky.",
"memberPortalOpenResource": "Otevřít zdroj",
"memberPortalPrivateResources": "Soukromé zdroje",
"memberPortalPrivateResourcesDescription": "Interní síťové zdroje přístupné přes klienta",
"memberPortalResourceDetails": "Podrobnosti o zdroji",
"memberPortalMode": "Režim",
"memberPortalDestination": "Cíl",
"memberPortalAlias": "Přezdívka",
"memberPortalCopiedAliasDescription": "Alias zdroje byl zkopírován do vaší schránky.",
"memberPortalCopiedDestinationDescription": "Cíl zdroje byl zkopírován do vaší schránky.",
"memberPortalRequiresClientConnection": "Vyžaduje klientské připojení",
"memberPortalAuthMethods": "Metody ověřování",
"memberPortalSso": "Jedno přihlášení (SSO)",
"memberPortalPasswordProtected": "Heslo chráněno",
"memberPortalPinCode": "PIN kód",
"memberPortalEmailWhitelist": "Seznam povolených emailů",
"memberPortalResourceDisabled": "Zdroj je zakázán",
"memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů",
"memberPortalPrevious": "Předchozí",
"memberPortalNext": "Následující"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.", "subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.",
"trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.", "trialBannerMessage": "Ihre Testversion läuft in {countdown} ab. Upgraden, um den Zugriff zu behalten.",
"trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.", "trialBannerExpired": "Ihre Testversion ist abgelaufen. Jetzt upgraden, um den Zugriff wiederherzustellen.",
"billingTrialBannerTitle": "Kostenlose Testversion aktiv",
"billingTrialBannerDescription": "Sie nutzen derzeit eine kostenlose Testversion auf der Geschäftsstufe. Wenn die Testversion endet, wird Ihr Konto automatisch auf die Funktionen und Beschränkungen der Basisstufe zurückgesetzt. Upgraden Sie jederzeit, um weiterhin Zugriff auf die Funktionen Ihres aktuellen Plans zu behalten.",
"billingTrialBannerUpgrade": "Jetzt upgraden",
"billingTrialBadge": "Kostenlose Testversion",
"trialActive": "Kostenlose Testversion aktiv", "trialActive": "Kostenlose Testversion aktiv",
"trialExpired": "Testversion abgelaufen", "trialExpired": "Testversion abgelaufen",
"trialHasEnded": "Ihre Testversion ist beendet.", "trialHasEnded": "Ihre Testversion ist beendet.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar", "noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar",
"ip": "IP", "ip": "IP",
"reason": "Grund", "reason": "Grund",
"requestLogs": "Logs anfordern", "requestLogs": "HTTP Anforderungsprotokolle",
"requestAnalytics": "Anfrage-Analyse anzeigen", "requestAnalytics": "Anfrage-Analyse anzeigen",
"host": "Host", "host": "Host",
"location": "Standort", "location": "Standort",
"actionLogs": "Aktionsprotokolle", "actionLogs": "Aktionsprotokolle",
"sidebarLogsRequest": "Logs anfordern", "sidebarLogsRequest": "HTTP Anforderungsprotokolle",
"sidebarLogsAccess": "Zugriffsprotokolle", "sidebarLogsAccess": "Zugriffsprotokolle",
"sidebarLogsAction": "Aktionsprotokolle", "sidebarLogsAction": "Aktionsprotokolle",
"logRetention": "Log-Speicherung", "logRetention": "Log-Speicherung",
"logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren", "logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren",
"requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen", "requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen",
"requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen", "requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen",
"logRetentionRequestLabel": "Log-Speicherung anfordern", "logRetentionRequestLabel": "HTTP Anforderungsprotokoll Aufbewahrung",
"logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden", "logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden",
"logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung", "logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung",
"logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen", "logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.", "httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.",
"httpDestConnectionLogsTitle": "Verbindungsprotokolle", "httpDestConnectionLogsTitle": "Verbindungsprotokolle",
"httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.", "httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.",
"httpDestRequestLogsTitle": "Logs anfordern", "httpDestRequestLogsTitle": "HTTP Anforderungsprotokolle",
"httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.", "httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.",
"httpDestSaveChanges": "Änderungen speichern", "httpDestSaveChanges": "Änderungen speichern",
"httpDestCreateDestination": "Ziel erstellen", "httpDestCreateDestination": "Ziel erstellen",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.", "domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.",
"domainPickerWildcardCertWarningLink": "Mehr erfahren", "domainPickerWildcardCertWarningLink": "Mehr erfahren",
"health": "Gesundheit", "health": "Gesundheit",
"domainPendingErrorTitle": "Verifizierungsproblem" "domainPendingErrorTitle": "Verifizierungsproblem",
"memberPortalTitle": "Ressourcen",
"memberPortalDescription": "Ressourcen, auf die Sie in dieser Organisation Zugriff haben",
"memberPortalSortBy": "Sortieren nach...",
"memberPortalSortNameAsc": "Name A-Z",
"memberPortalSortNameDesc": "Name Z-A",
"memberPortalSortDomainAsc": "Domain A-Z",
"memberPortalSortDomainDesc": "Domain Z-A",
"memberPortalSortEnabledFirst": "Zuerst aktiviert",
"memberPortalSortDisabledFirst": "Zuerst deaktiviert",
"memberPortalRefresh": "Aktualisieren",
"memberPortalRefreshResources": "Ressourcen aktualisieren",
"memberPortalFailedToLoad": "Fehler beim Laden der Ressourcen",
"memberPortalFailedToLoadDescription": "Fehler beim Laden der Ressourcen. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"memberPortalUnableToLoad": "Ressourcen konnten nicht geladen werden",
"memberPortalTryAgain": "Nochmal versuchen",
"memberPortalNoResourcesFound": "Keine Ressourcen gefunden",
"memberPortalNoResourcesAvailable": "Keine Ressourcen verfügbar",
"memberPortalNoResourcesMatchSearch": "Keine Ressourcen passen zu \"{query}\". Versuchen Sie, Ihre Suchbegriffe anzupassen oder die Suche zu löschen, um alle Ressourcen anzuzeigen.",
"memberPortalNoResourcesAccess": "Sie haben noch keinen Zugriff auf Ressourcen. Wenden Sie sich an Ihren Administrator, um Zugriff auf die benötigten Ressourcen zu erhalten.",
"memberPortalClearSearch": "Suchverlauf löschen",
"memberPortalPublicResources": "Öffentliche Ressourcen",
"memberPortalPublicResourcesDescription": "Webanwendungen und Dienste, die über den Browser zugänglich sind",
"memberPortalCopiedToClipboard": "In die Zwischenablage kopiert",
"memberPortalCopiedUrlDescription": "Ressourcen-URL wurde in Ihre Zwischenablage kopiert.",
"memberPortalOpenResource": "Ressource öffnen",
"memberPortalPrivateResources": "Private Ressourcen",
"memberPortalPrivateResourcesDescription": "Interne Netzwerkressourcen, die über den Client zugänglich sind",
"memberPortalResourceDetails": "Ressourcendetails",
"memberPortalMode": "Modus",
"memberPortalDestination": "Ziel",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "Ressourcenalias wurde in Ihre Zwischenablage kopiert.",
"memberPortalCopiedDestinationDescription": "Ressourcenziel wurde in Ihre Zwischenablage kopiert.",
"memberPortalRequiresClientConnection": "Erfordert Client-Verbindung",
"memberPortalAuthMethods": "Authentifizierungsmethoden",
"memberPortalSso": "Single Sign-On (SSO)",
"memberPortalPasswordProtected": "Passwortgeschützt",
"memberPortalPinCode": "PIN-Code",
"memberPortalEmailWhitelist": "E-Mail-Whitelist",
"memberPortalResourceDisabled": "Ressource deaktiviert",
"memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen",
"memberPortalPrevious": "Vorherige",
"memberPortalNext": "Nächste"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.", "trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
"trialBannerExpired": "Your trial has expired. Upgrade now to restore access.", "trialBannerExpired": "Your trial has expired. Upgrade now to restore access.",
"billingTrialBannerTitle": "Free Trial Active",
"billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.",
"billingTrialBannerUpgrade": "Upgrade Now",
"billingTrialBadge": "Free Trial",
"trialActive": "Free Trial Active", "trialActive": "Free Trial Active",
"trialExpired": "Trial Expired", "trialExpired": "Trial Expired",
"trialHasEnded": "Your trial has ended.", "trialHasEnded": "Your trial has ended.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Reason", "reason": "Reason",
"requestLogs": "HTTPS Request Logs", "requestLogs": "HTTP Request Logs",
"requestAnalytics": "Request Analytics", "requestAnalytics": "Request Analytics",
"host": "Host", "host": "Host",
"location": "Location", "location": "Location",
"actionLogs": "Admin Action Logs", "actionLogs": "Admin Action Logs",
"sidebarLogsRequest": "HTTPS Request Logs", "sidebarLogsRequest": "HTTP Request Logs",
"sidebarLogsAccess": "Authentication Logs", "sidebarLogsAccess": "Authentication Logs",
"sidebarLogsAction": "Admin Action Logs", "sidebarLogsAction": "Admin Action Logs",
"logRetention": "Log Retention", "logRetention": "Log Retention",
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization", "requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
"logRetentionRequestLabel": "HTTPS Request Log Retention", "logRetentionRequestLabel": "HTTP Request Log Retention",
"logRetentionRequestDescription": "How long to retain request logs", "logRetentionRequestDescription": "How long to retain request logs",
"logRetentionAccessLabel": "Authentication Log Retention", "logRetentionAccessLabel": "Authentication Log Retention",
"logRetentionAccessDescription": "How long to retain access logs", "logRetentionAccessDescription": "How long to retain access logs",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.",
"httpDestConnectionLogsTitle": "Network Logs", "httpDestConnectionLogsTitle": "Network Logs",
"httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.",
"httpDestRequestLogsTitle": "HTTPS Request Logs", "httpDestRequestLogsTitle": "HTTP Request Logs",
"httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.",
"httpDestSaveChanges": "Save Changes", "httpDestSaveChanges": "Save Changes",
"httpDestCreateDestination": "Create Destination", "httpDestCreateDestination": "Create Destination",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
"domainPickerWildcardCertWarningLink": "Learn more", "domainPickerWildcardCertWarningLink": "Learn more",
"health": "Health", "health": "Health",
"domainPendingErrorTitle": "Verification Issue" "domainPendingErrorTitle": "Verification Issue",
"memberPortalTitle": "Resources",
"memberPortalDescription": "Resources you have access to in this organization",
"memberPortalSortBy": "Sort by...",
"memberPortalSortNameAsc": "Name A-Z",
"memberPortalSortNameDesc": "Name Z-A",
"memberPortalSortDomainAsc": "Domain A-Z",
"memberPortalSortDomainDesc": "Domain Z-A",
"memberPortalSortEnabledFirst": "Enabled First",
"memberPortalSortDisabledFirst": "Disabled First",
"memberPortalRefresh": "Refresh",
"memberPortalRefreshResources": "Refresh Resources",
"memberPortalFailedToLoad": "Failed to load resources",
"memberPortalFailedToLoadDescription": "Failed to load resources. Please check your connection and try again.",
"memberPortalUnableToLoad": "Unable to Load Resources",
"memberPortalTryAgain": "Try Again",
"memberPortalNoResourcesFound": "No Resources Found",
"memberPortalNoResourcesAvailable": "No Resources Available",
"memberPortalNoResourcesMatchSearch": "No resources match \"{query}\". Try adjusting your search terms or clearing the search to see all resources.",
"memberPortalNoResourcesAccess": "You don't have access to any resources yet. Contact your administrator to get access to resources you need.",
"memberPortalClearSearch": "Clear Search",
"memberPortalPublicResources": "Public Resources",
"memberPortalPublicResourcesDescription": "Web applications and services accessible via browser",
"memberPortalCopiedToClipboard": "Copied to clipboard",
"memberPortalCopiedUrlDescription": "Resource URL has been copied to your clipboard.",
"memberPortalOpenResource": "Open Resource",
"memberPortalPrivateResources": "Private Resources",
"memberPortalPrivateResourcesDescription": "Internal network resources accessible via client",
"memberPortalResourceDetails": "Resource Details",
"memberPortalMode": "Mode",
"memberPortalDestination": "Destination",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "Resource alias has been copied to your clipboard.",
"memberPortalCopiedDestinationDescription": "Resource destination has been copied to your clipboard.",
"memberPortalRequiresClientConnection": "Requires Client Connection",
"memberPortalAuthMethods": "Authentication Methods",
"memberPortalSso": "Single Sign-On (SSO)",
"memberPortalPasswordProtected": "Password Protected",
"memberPortalPinCode": "PIN Code",
"memberPortalEmailWhitelist": "Email Whitelist",
"memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"memberPortalPrevious": "Previous",
"memberPortalNext": "Next"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.", "subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.",
"trialBannerMessage": "Su prueba expira en {countdown}. Actualice para mantener el acceso.", "trialBannerMessage": "Su prueba expira en {countdown}. Actualice para mantener el acceso.",
"trialBannerExpired": "Su prueba ha expirado. Actualice ahora para restaurar el acceso.", "trialBannerExpired": "Su prueba ha expirado. Actualice ahora para restaurar el acceso.",
"billingTrialBannerTitle": "Prueba gratuita activada",
"billingTrialBannerDescription": "Actualmente estás en una prueba gratuita en el nivel empresarial. Cuando finalice la prueba, tu cuenta volverá automáticamente a las características y límites del nivel Básico. Mejora en cualquier momento para mantener el acceso a las características de tu plan actual.",
"billingTrialBannerUpgrade": "Actualizar ahora",
"billingTrialBadge": "Prueba Gratuita",
"trialActive": "Prueba gratuita activa", "trialActive": "Prueba gratuita activa",
"trialExpired": "Prueba expirada", "trialExpired": "Prueba expirada",
"trialHasEnded": "Su prueba ha terminado.", "trialHasEnded": "Su prueba ha terminado.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Razón", "reason": "Razón",
"requestLogs": "Registros de Solicitud", "requestLogs": "Registros de Solicitud HTTP",
"requestAnalytics": "Analítica de Solicitud", "requestAnalytics": "Analítica de Solicitud",
"host": "Anfitrión", "host": "Anfitrión",
"location": "Ubicación", "location": "Ubicación",
"actionLogs": "Registros de acción", "actionLogs": "Registros de acción",
"sidebarLogsRequest": "Registros de Solicitud", "sidebarLogsRequest": "Registros de Solicitud HTTP",
"sidebarLogsAccess": "Registros de acceso", "sidebarLogsAccess": "Registros de acceso",
"sidebarLogsAction": "Registros de acción", "sidebarLogsAction": "Registros de acción",
"logRetention": "Retención de Log", "logRetention": "Retención de Log",
"logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos", "logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos",
"requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización", "requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización",
"requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización", "requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización",
"logRetentionRequestLabel": "Retención de Registro de Solicitud", "logRetentionRequestLabel": "Retención de Registro de Solicitud HTTP",
"logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes", "logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes",
"logRetentionAccessLabel": "Retención de Log de Acceso", "logRetentionAccessLabel": "Retención de Log de Acceso",
"logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso", "logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.", "httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.",
"httpDestConnectionLogsTitle": "Registros de conexión", "httpDestConnectionLogsTitle": "Registros de conexión",
"httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.", "httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.",
"httpDestRequestLogsTitle": "Registros de Solicitud", "httpDestRequestLogsTitle": "Registros de Solicitud HTTP",
"httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.", "httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.",
"httpDestSaveChanges": "Guardar Cambios", "httpDestSaveChanges": "Guardar Cambios",
"httpDestCreateDestination": "Crear destino", "httpDestCreateDestination": "Crear destino",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.", "domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.",
"domainPickerWildcardCertWarningLink": "Más información", "domainPickerWildcardCertWarningLink": "Más información",
"health": "Salud", "health": "Salud",
"domainPendingErrorTitle": "Problema de verificación" "domainPendingErrorTitle": "Problema de verificación",
"memberPortalTitle": "Recursos",
"memberPortalDescription": "Recursos a los que tiene acceso en esta organización",
"memberPortalSortBy": "Ordenar por...",
"memberPortalSortNameAsc": "Nombre A-Z",
"memberPortalSortNameDesc": "Nombre Z-A",
"memberPortalSortDomainAsc": "Dominio A-Z",
"memberPortalSortDomainDesc": "Dominio Z-A",
"memberPortalSortEnabledFirst": "Habilitado Primero",
"memberPortalSortDisabledFirst": "Deshabilitado Primero",
"memberPortalRefresh": "Actualizar",
"memberPortalRefreshResources": "Actualizar Recursos",
"memberPortalFailedToLoad": "No se pudieron cargar los recursos",
"memberPortalFailedToLoadDescription": "No se pudieron cargar los recursos. Por favor, revise su conexión e intente de nuevo.",
"memberPortalUnableToLoad": "No se pudieron cargar los recursos",
"memberPortalTryAgain": "Intentar de Nuevo",
"memberPortalNoResourcesFound": "No se encontraron Recursos",
"memberPortalNoResourcesAvailable": "No Hay Recursos Disponibles",
"memberPortalNoResourcesMatchSearch": "No hay recursos que coincidan con \"{query}\". Intenta ajustar tus términos de búsqueda o limpiar la búsqueda para ver todos los recursos.",
"memberPortalNoResourcesAccess": "Aún no tiene acceso a ningún recurso. Comuníquese con su administrador para obtener acceso a los recursos que necesita.",
"memberPortalClearSearch": "Limpiar Búsqueda",
"memberPortalPublicResources": "Recursos Públicos",
"memberPortalPublicResourcesDescription": "Aplicaciones web y servicios accesibles vía navegador",
"memberPortalCopiedToClipboard": "Copiado al portapapeles",
"memberPortalCopiedUrlDescription": "La URL del recurso ha sido copiada a su portapapeles.",
"memberPortalOpenResource": "Abrir Recurso",
"memberPortalPrivateResources": "Recursos Privados",
"memberPortalPrivateResourcesDescription": "Recursos de red interna accesibles vía cliente",
"memberPortalResourceDetails": "Detalles del Recurso",
"memberPortalMode": "Modo",
"memberPortalDestination": "Destino",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "El alias del recurso ha sido copiado a su portapapeles.",
"memberPortalCopiedDestinationDescription": "El destino del recurso ha sido copiado a su portapapeles.",
"memberPortalRequiresClientConnection": "Requiere Conexión de Cliente",
"memberPortalAuthMethods": "Métodos de Autenticación",
"memberPortalSso": "Inicio de Sesión Único (SSO)",
"memberPortalPasswordProtected": "Protegido por Contraseña",
"memberPortalPinCode": "Código PIN",
"memberPortalEmailWhitelist": "Lista Blanca de Correo",
"memberPortalResourceDisabled": "Recurso Deshabilitado",
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
"memberPortalPrevious": "Anterior",
"memberPortalNext": "Siguiente"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.", "subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.",
"trialBannerMessage": "Votre essai expire dans {countdown}. Passez à l'abonnement pour garder l'accès.", "trialBannerMessage": "Votre essai expire dans {countdown}. Passez à l'abonnement pour garder l'accès.",
"trialBannerExpired": "Votre essai a expiré. Passez à l'abonnement maintenant pour restaurer l'accès.", "trialBannerExpired": "Votre essai a expiré. Passez à l'abonnement maintenant pour restaurer l'accès.",
"billingTrialBannerTitle": "Essai gratuit actif",
"billingTrialBannerDescription": "Vous êtes actuellement en essai gratuit sur le niveau business. À la fin de l'essai, votre compte basculera automatiquement aux fonctionnalités et limites du niveau Basique. Mettez à jour à tout moment pour conserver l'accès aux fonctionnalités de votre plan actuel.",
"billingTrialBannerUpgrade": "Passer à la version supérieure maintenant",
"billingTrialBadge": "Essai gratuit",
"trialActive": "Essai gratuit actif", "trialActive": "Essai gratuit actif",
"trialExpired": "Essai expiré", "trialExpired": "Essai expiré",
"trialHasEnded": "Votre essai est terminé.", "trialHasEnded": "Votre essai est terminé.",
@@ -1352,7 +1356,7 @@
"sidebarSites": "Nœuds", "sidebarSites": "Nœuds",
"sidebarApprovals": "Demandes d'approbation", "sidebarApprovals": "Demandes d'approbation",
"sidebarResources": "Ressource", "sidebarResources": "Ressource",
"sidebarProxyResources": "Publique", "sidebarProxyResources": "Publiques",
"sidebarClientResources": "Privé", "sidebarClientResources": "Privé",
"sidebarAccessControl": "Contrôle d'accès", "sidebarAccessControl": "Contrôle d'accès",
"sidebarLogsAndAnalytics": "Journaux & Analytiques", "sidebarLogsAndAnalytics": "Journaux & Analytiques",
@@ -2454,8 +2458,8 @@
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources", "manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
"downloadClientBannerTitle": "Télécharger le client Pangolin", "downloadClientBannerTitle": "Télécharger le client Pangolin",
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.", "downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
"manageMachineClients": "Gérer les clients de la machine", "manageMachineClients": "Gérer les machines",
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources", "manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources",
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés", "machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.", "machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
"machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerPangolinCLI": "Pangolin CLI",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Raison", "reason": "Raison",
"requestLogs": "Journal des requêtes", "requestLogs": "Journal des Requêtes HTTP",
"requestAnalytics": "Demander des analyses", "requestAnalytics": "Demander des analyses",
"host": "Hôte", "host": "Hôte",
"location": "Localisation", "location": "Localisation",
"actionLogs": "Journaux des actions", "actionLogs": "Journaux des actions",
"sidebarLogsRequest": "Journal des requêtes", "sidebarLogsRequest": "Journal des Requêtes HTTP",
"sidebarLogsAccess": "Journaux d'accès", "sidebarLogsAccess": "Journaux d'accès",
"sidebarLogsAction": "Journaux des actions", "sidebarLogsAction": "Journaux des actions",
"logRetention": "Journaliser la rétention", "logRetention": "Journaliser la rétention",
"logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver", "logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver",
"requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation", "requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation",
"requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation", "requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation",
"logRetentionRequestLabel": "Demander la rétention des journaux", "logRetentionRequestLabel": "Rétention des Journaux de Requêtes HTTP",
"logRetentionRequestDescription": "Durée de conservation des journaux de requêtes", "logRetentionRequestDescription": "Durée de conservation des journaux de requêtes",
"logRetentionAccessLabel": "Rétention du journal d'accès", "logRetentionAccessLabel": "Rétention du journal d'accès",
"logRetentionAccessDescription": "Durée de conservation des journaux d'accès", "logRetentionAccessDescription": "Durée de conservation des journaux d'accès",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.", "httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.",
"httpDestConnectionLogsTitle": "Journaux de connexion", "httpDestConnectionLogsTitle": "Journaux de connexion",
"httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.", "httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.",
"httpDestRequestLogsTitle": "Journal des requêtes", "httpDestRequestLogsTitle": "Journal des Requêtes HTTP",
"httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.", "httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.",
"httpDestSaveChanges": "Enregistrer les modifications", "httpDestSaveChanges": "Enregistrer les modifications",
"httpDestCreateDestination": "Créer une destination", "httpDestCreateDestination": "Créer une destination",
@@ -3150,6 +3154,7 @@
"healthCheckTabAdvanced": "Avancé", "healthCheckTabAdvanced": "Avancé",
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.", "healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
"uptime30d": "Disponibilité (30j)", "uptime30d": "Disponibilité (30j)",
"uptimeNoData": "Aucune donnée",
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité", "idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
"idpAddActionImportFromOrg": "Importer d'une autre organisation", "idpAddActionImportFromOrg": "Importer d'une autre organisation",
"idpImportDialogTitle": "Importer le fournisseur d'identité", "idpImportDialogTitle": "Importer le fournisseur d'identité",
@@ -3204,5 +3209,48 @@
"domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.", "domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.",
"domainPickerWildcardCertWarningLink": "En savoir plus", "domainPickerWildcardCertWarningLink": "En savoir plus",
"health": "Santé", "health": "Santé",
"domainPendingErrorTitle": "Problème de vérification" "domainPendingErrorTitle": "Problème de vérification",
"memberPortalTitle": "Ressources",
"memberPortalDescription": "Ressources auxquelles vous avez accès dans cette organisation",
"memberPortalSortBy": "Trier par...",
"memberPortalSortNameAsc": "Nom A-Z",
"memberPortalSortNameDesc": "Nom Z-A",
"memberPortalSortDomainAsc": "Domaine A-Z",
"memberPortalSortDomainDesc": "Domaine Z-A",
"memberPortalSortEnabledFirst": "Activé en premier",
"memberPortalSortDisabledFirst": "Désactivé en premier",
"memberPortalRefresh": "Actualiser",
"memberPortalRefreshResources": "Actualiser les ressources",
"memberPortalFailedToLoad": "Échec du chargement des ressources",
"memberPortalFailedToLoadDescription": "Échec du chargement des ressources. Veuillez vérifier votre connexion et réessayer.",
"memberPortalUnableToLoad": "Impossible de charger les ressources",
"memberPortalTryAgain": "Réessayer",
"memberPortalNoResourcesFound": "Aucune ressource trouvée",
"memberPortalNoResourcesAvailable": "Aucune ressource disponible",
"memberPortalNoResourcesMatchSearch": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster vos termes de recherche ou de vider la recherche pour voir toutes les ressources.",
"memberPortalNoResourcesAccess": "Vous n'avez encore accès à aucune ressource. Contactez votre administrateur pour obtenir l'accès aux ressources dont vous avez besoin.",
"memberPortalClearSearch": "Effacer la recherche",
"memberPortalPublicResources": "Ressources publiques",
"memberPortalPublicResourcesDescription": "Applications et services web accessibles via un navigateur",
"memberPortalCopiedToClipboard": "Copié dans le presse-papiers",
"memberPortalCopiedUrlDescription": "L'URL de la ressource a été copiée dans votre presse-papiers.",
"memberPortalOpenResource": "Ouvrir la ressource",
"memberPortalPrivateResources": "Ressources privées",
"memberPortalPrivateResourcesDescription": "Ressources réseau internes accessibles via un client",
"memberPortalResourceDetails": "Détails de la ressource",
"memberPortalMode": "Mode",
"memberPortalDestination": "Destination",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "L'alias de la ressource a été copié dans votre presse-papiers.",
"memberPortalCopiedDestinationDescription": "La destination de la ressource a été copiée dans votre presse-papiers.",
"memberPortalRequiresClientConnection": "Nécessite une connexion client",
"memberPortalAuthMethods": "Méthodes d'authentification",
"memberPortalSso": "Authentification unique (SSO)",
"memberPortalPasswordProtected": "Protégé par un mot de passe",
"memberPortalPinCode": "Code PIN",
"memberPortalEmailWhitelist": "Liste blanche des e-mails",
"memberPortalResourceDisabled": "Ressource désactivée",
"memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources",
"memberPortalPrevious": "Précédent",
"memberPortalNext": "Suivant"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.", "subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.",
"trialBannerMessage": "Il tuo periodo di prova scade tra {countdown}. Aggiorna per mantenere l'accesso.", "trialBannerMessage": "Il tuo periodo di prova scade tra {countdown}. Aggiorna per mantenere l'accesso.",
"trialBannerExpired": "Il tuo periodo di prova è scaduto. Aggiorna ora per ripristinare l'accesso.", "trialBannerExpired": "Il tuo periodo di prova è scaduto. Aggiorna ora per ripristinare l'accesso.",
"billingTrialBannerTitle": "Prova Gratuita Attiva",
"billingTrialBannerDescription": "Attualmente sei in una prova gratuita sul livello business. Quando la prova terminerà, il tuo account tornerà automaticamente alle funzionalità e ai limiti del piano Basic. Effettua l'upgrade in qualsiasi momento per mantenere l'accesso alle funzionalità del tuo piano attuale.",
"billingTrialBannerUpgrade": "Effettua l'Upgrade Ora",
"billingTrialBadge": "Prova Gratuita",
"trialActive": "Prova Gratuita Attiva", "trialActive": "Prova Gratuita Attiva",
"trialExpired": "Prova scaduta", "trialExpired": "Prova scaduta",
"trialHasEnded": "La tua prova è terminata.", "trialHasEnded": "La tua prova è terminata.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Motivo", "reason": "Motivo",
"requestLogs": "Log Richiesta", "requestLogs": "Log Richieste HTTP",
"requestAnalytics": "Richiedi Analisi", "requestAnalytics": "Richiedi Analisi",
"host": "Host", "host": "Host",
"location": "Posizione", "location": "Posizione",
"actionLogs": "Log Azioni", "actionLogs": "Log Azioni",
"sidebarLogsRequest": "Log Richiesta", "sidebarLogsRequest": "Log Richieste HTTP",
"sidebarLogsAccess": "Log Accesso", "sidebarLogsAccess": "Log Accesso",
"sidebarLogsAction": "Log Azioni", "sidebarLogsAction": "Log Azioni",
"logRetention": "Ritenzione Registro", "logRetention": "Ritenzione Registro",
"logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali", "logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali",
"requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione", "requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione",
"requestAnalyticsDescription": "Visualizza le analisi dettagliate della richiesta per le risorse in questa organizzazione", "requestAnalyticsDescription": "Visualizza le analisi dettagliate della richiesta per le risorse in questa organizzazione",
"logRetentionRequestLabel": "Richiedi Ritenzione Log", "logRetentionRequestLabel": "Conservazione Log Richieste HTTP",
"logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste", "logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste",
"logRetentionAccessLabel": "Ritenzione Registro Accesso", "logRetentionAccessLabel": "Ritenzione Registro Accesso",
"logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso", "logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.", "httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.",
"httpDestConnectionLogsTitle": "Log Di Connessione", "httpDestConnectionLogsTitle": "Log Di Connessione",
"httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.", "httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.",
"httpDestRequestLogsTitle": "Log Richiesta", "httpDestRequestLogsTitle": "Log Richieste HTTP",
"httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.", "httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.",
"httpDestSaveChanges": "Salva Modifiche", "httpDestSaveChanges": "Salva Modifiche",
"httpDestCreateDestination": "Crea Destinazione", "httpDestCreateDestination": "Crea Destinazione",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.", "domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.",
"domainPickerWildcardCertWarningLink": "Scopri di più", "domainPickerWildcardCertWarningLink": "Scopri di più",
"health": "Salute", "health": "Salute",
"domainPendingErrorTitle": "Problema di Verifica" "domainPendingErrorTitle": "Problema di Verifica",
"memberPortalTitle": "Risorse",
"memberPortalDescription": "Risorse a cui hai accesso in questa organizzazione",
"memberPortalSortBy": "Ordina per...",
"memberPortalSortNameAsc": "Nome A-Z",
"memberPortalSortNameDesc": "Nome Z-A",
"memberPortalSortDomainAsc": "Dominio A-Z",
"memberPortalSortDomainDesc": "Dominio Z-A",
"memberPortalSortEnabledFirst": "Abilitati per primi",
"memberPortalSortDisabledFirst": "Disabilitati per primi",
"memberPortalRefresh": "Aggiorna",
"memberPortalRefreshResources": "Aggiorna Risorse",
"memberPortalFailedToLoad": "Caricamento delle risorse non riuscito",
"memberPortalFailedToLoadDescription": "Caricamento delle risorse non riuscito. Controlla la tua connessione e riprova.",
"memberPortalUnableToLoad": "Impossibile caricare le risorse",
"memberPortalTryAgain": "Riprova",
"memberPortalNoResourcesFound": "Nessuna risorsa trovata",
"memberPortalNoResourcesAvailable": "Nessuna risorsa disponibile",
"memberPortalNoResourcesMatchSearch": "Nessuna risorsa corrisponde a \"{query}\". Prova ad aggiustare i termini di ricerca o a cancellare la ricerca per vedere tutte le risorse.",
"memberPortalNoResourcesAccess": "Non hai ancora accesso a nessuna risorsa. Contatta il tuo amministratore per ottenere l'accesso alle risorse di cui hai bisogno.",
"memberPortalClearSearch": "Cancella Ricerca",
"memberPortalPublicResources": "Risorse Pubbliche",
"memberPortalPublicResourcesDescription": "Applicazioni web e servizi accessibili tramite browser",
"memberPortalCopiedToClipboard": "Copiato negli appunti",
"memberPortalCopiedUrlDescription": "L'URL della risorsa è stato copiato negli appunti.",
"memberPortalOpenResource": "Apri Risorsa",
"memberPortalPrivateResources": "Risorse Private",
"memberPortalPrivateResourcesDescription": "Risorse di rete interne accessibili tramite client",
"memberPortalResourceDetails": "Dettagli della Risorsa",
"memberPortalMode": "Modalità",
"memberPortalDestination": "Destinazione",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "L'alias della risorsa è stato copiato negli appunti.",
"memberPortalCopiedDestinationDescription": "La destinazione della risorsa è stata copiata negli appunti.",
"memberPortalRequiresClientConnection": "Richiede Connessione Client",
"memberPortalAuthMethods": "Metodi di Autenticazione",
"memberPortalSso": "Accesso unico (Single Sign-On, SSO)",
"memberPortalPasswordProtected": "Protetto da password",
"memberPortalPinCode": "Codice PIN",
"memberPortalEmailWhitelist": "Lista Autorizzazioni Email",
"memberPortalResourceDisabled": "Risorsa Disabilitata",
"memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse",
"memberPortalPrevious": "Precedente",
"memberPortalNext": "Successivo"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.", "subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.",
"trialBannerMessage": "시험 사용 기간이 {countdown} 안에 만료됩니다. 업그레이드하여 액세스를 유지하세요.", "trialBannerMessage": "시험 사용 기간이 {countdown} 안에 만료됩니다. 업그레이드하여 액세스를 유지하세요.",
"trialBannerExpired": "시험 사용 기간이 만료되었습니다. 지금 업그레이드하여 액세스를 복구하세요.", "trialBannerExpired": "시험 사용 기간이 만료되었습니다. 지금 업그레이드하여 액세스를 복구하세요.",
"billingTrialBannerTitle": "무료 평가판 활성화",
"billingTrialBannerDescription": "현재 비즈니스 티어의 무료 평가판을 사용 중입니다. 평가판이 종료되면 계정은 자동으로 기본 티어 기능 및 제한으로 돌아갑니다. 현재 계획의 기능을 유지하려면 언제든지 업그레이드 하세요.",
"billingTrialBannerUpgrade": "지금 업그레이드",
"billingTrialBadge": "무료 평가판",
"trialActive": "무료 체험 활성화됨", "trialActive": "무료 체험 활성화됨",
"trialExpired": "체험 만료됨", "trialExpired": "체험 만료됨",
"trialHasEnded": "시험 사용 기간이 종료되었습니다.", "trialHasEnded": "시험 사용 기간이 종료되었습니다.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "유효한 인증 없음", "noMoreAuthMethods": "유효한 인증 없음",
"ip": "IP", "ip": "IP",
"reason": "이유", "reason": "이유",
"requestLogs": "요청 로그", "requestLogs": "HTTP 요청 로그",
"requestAnalytics": "요청 분석", "requestAnalytics": "요청 분석",
"host": "호스트", "host": "호스트",
"location": "위치", "location": "위치",
"actionLogs": "작업 로그", "actionLogs": "작업 로그",
"sidebarLogsRequest": "요청 로그", "sidebarLogsRequest": "HTTP 요청 로그",
"sidebarLogsAccess": "접근 로그", "sidebarLogsAccess": "접근 로그",
"sidebarLogsAction": "작업 로그", "sidebarLogsAction": "작업 로그",
"logRetention": "로그 보관", "logRetention": "로그 보관",
"logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다", "logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다",
"requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다", "requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다",
"requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기", "requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기",
"logRetentionRequestLabel": "요청 로그 보관", "logRetentionRequestLabel": "HTTP 요청 로그 보관",
"logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지", "logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지",
"logRetentionAccessLabel": "접근 로그 보관", "logRetentionAccessLabel": "접근 로그 보관",
"logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지", "logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.", "httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.",
"httpDestConnectionLogsTitle": "연결 로그", "httpDestConnectionLogsTitle": "연결 로그",
"httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.", "httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.",
"httpDestRequestLogsTitle": "요청 로그", "httpDestRequestLogsTitle": "HTTP 요청 로그",
"httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.", "httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.",
"httpDestSaveChanges": "변경 사항 저장", "httpDestSaveChanges": "변경 사항 저장",
"httpDestCreateDestination": "대상지 생성", "httpDestCreateDestination": "대상지 생성",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.", "domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.",
"domainPickerWildcardCertWarningLink": "자세히 알아보기", "domainPickerWildcardCertWarningLink": "자세히 알아보기",
"health": "건강", "health": "건강",
"domainPendingErrorTitle": "확인 문제" "domainPendingErrorTitle": "확인 문제",
"memberPortalTitle": "리소스",
"memberPortalDescription": "이 조직에서 접근할 수 있는 리소스",
"memberPortalSortBy": "정렬 기준...",
"memberPortalSortNameAsc": "이름 A-Z",
"memberPortalSortNameDesc": "이름 Z-A",
"memberPortalSortDomainAsc": "도메인 A-Z",
"memberPortalSortDomainDesc": "도메인 Z-A",
"memberPortalSortEnabledFirst": "사용 활성화 우선",
"memberPortalSortDisabledFirst": "사용 비활성화 우선",
"memberPortalRefresh": "새로 고침",
"memberPortalRefreshResources": "리소스 새로 고침",
"memberPortalFailedToLoad": "리소스를 불러오는 데 실패했습니다",
"memberPortalFailedToLoadDescription": "리소스를 불러오는 데 실패했습니다. 연결을 확인하고 다시 시도해 주십시오.",
"memberPortalUnableToLoad": "리소스를 가져오는 데 실패했습니다",
"memberPortalTryAgain": "다시 시도",
"memberPortalNoResourcesFound": "리소스를 발견하지 못했습니다",
"memberPortalNoResourcesAvailable": "사용 가능한 리소스가 없습니다",
"memberPortalNoResourcesMatchSearch": "\"{query}\"와 일치하는 리소스가 없습니다. 검색어를 수정하거나 검색을 초기화하여 모든 리소스를 확인하십시오.",
"memberPortalNoResourcesAccess": "아직 접근할 수 있는 리소스가 없습니다. 필요한 리소스 접근을 위해 관리자에게 문의하세요.",
"memberPortalClearSearch": "검색 초기화",
"memberPortalPublicResources": "공공 리소스",
"memberPortalPublicResourcesDescription": "브라우저를 통해 접근 가능한 웹 애플리케이션 및 서비스",
"memberPortalCopiedToClipboard": "클립보드에 복사됨",
"memberPortalCopiedUrlDescription": "리소스 URL이 클립보드에 복사되었습니다.",
"memberPortalOpenResource": "리소스 열기",
"memberPortalPrivateResources": "비공개 리소스",
"memberPortalPrivateResourcesDescription": "클라이언트를 통해 접근 가능한 내부 네트워크 리소스",
"memberPortalResourceDetails": "리소스 세부 정보",
"memberPortalMode": "모드",
"memberPortalDestination": "대상지",
"memberPortalAlias": "별칭",
"memberPortalCopiedAliasDescription": "리소스 별칭이 클립보드에 복사되었습니다.",
"memberPortalCopiedDestinationDescription": "리소스 대상지가 클립보드에 복사되었습니다.",
"memberPortalRequiresClientConnection": "클라이언트 연결 필요",
"memberPortalAuthMethods": "인증 방법",
"memberPortalSso": "싱글 사인온 (SSO)",
"memberPortalPasswordProtected": "비밀번호 보호",
"memberPortalPinCode": "PIN 코드",
"memberPortalEmailWhitelist": "이메일 화이트리스트",
"memberPortalResourceDisabled": "리소스 비활성화됨",
"memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중",
"memberPortalPrevious": "이전",
"memberPortalNext": "다음"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.", "subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.",
"trialBannerMessage": "Din prøveperiode utløper om {countdown}. Oppgrader for å beholde tilgangen.", "trialBannerMessage": "Din prøveperiode utløper om {countdown}. Oppgrader for å beholde tilgangen.",
"trialBannerExpired": "Prøveperioden din har utløpt. Oppgrader nå for å gjenopprette tilgangen.", "trialBannerExpired": "Prøveperioden din har utløpt. Oppgrader nå for å gjenopprette tilgangen.",
"billingTrialBannerTitle": "Prøveversjon Aktiv",
"billingTrialBannerDescription": "Du har for øyeblikket en gratis prøveversjon på forretningsnivået. Når prøven avsluttes, vil kontoen din automatisk gå tilbake til funksjoner og begrensninger på Basis-nivået. Oppgrader når som helst for å beholde tilgang til de nåværende planens funksjoner.",
"billingTrialBannerUpgrade": "Oppgrader nå",
"billingTrialBadge": "Prøveversjon",
"trialActive": "Gratis prøveversjon aktiv", "trialActive": "Gratis prøveversjon aktiv",
"trialExpired": "Prøveperioden er utløpt", "trialExpired": "Prøveperioden er utløpt",
"trialHasEnded": "Din prøveperiode har avsluttet.", "trialHasEnded": "Din prøveperiode har avsluttet.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Grunn", "reason": "Grunn",
"requestLogs": "Forespørselslogger (Automatic Translation)", "requestLogs": "HTTP-forespørselslogger",
"requestAnalytics": "Be om analyser", "requestAnalytics": "Be om analyser",
"host": "Vert", "host": "Vert",
"location": "Sted", "location": "Sted",
"actionLogs": "Handlingslogger", "actionLogs": "Handlingslogger",
"sidebarLogsRequest": "Forespørselslogger (Automatic Translation)", "sidebarLogsRequest": "HTTP-forespørselslogger",
"sidebarLogsAccess": "Tilgangslogger (Automatic Translation)", "sidebarLogsAccess": "Tilgangslogger (Automatic Translation)",
"sidebarLogsAction": "Handlingslogger", "sidebarLogsAction": "Handlingslogger",
"logRetention": "Logg tilbaketrekning", "logRetention": "Logg tilbaketrekning",
"logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem", "logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem",
"requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen", "requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen",
"requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen", "requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen",
"logRetentionRequestLabel": "Be om loggoverføring", "logRetentionRequestLabel": "Be om loggbevaring",
"logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger", "logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger",
"logRetentionAccessLabel": "Få tilgang til loggoverføring", "logRetentionAccessLabel": "Få tilgang til loggoverføring",
"logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger", "logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.", "httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.",
"httpDestConnectionLogsTitle": "Loggfiler for tilkobling", "httpDestConnectionLogsTitle": "Loggfiler for tilkobling",
"httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.", "httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.",
"httpDestRequestLogsTitle": "Forespørselslogger (Automatic Translation)", "httpDestRequestLogsTitle": "HTTP-forespørselslogger",
"httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.", "httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.",
"httpDestSaveChanges": "Lagre endringer", "httpDestSaveChanges": "Lagre endringer",
"httpDestCreateDestination": "Opprett mål", "httpDestCreateDestination": "Opprett mål",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.", "domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.",
"domainPickerWildcardCertWarningLink": "Lær mer", "domainPickerWildcardCertWarningLink": "Lær mer",
"health": "Helse", "health": "Helse",
"domainPendingErrorTitle": "Verifiseringsproblem" "domainPendingErrorTitle": "Verifiseringsproblem",
"memberPortalTitle": "Ressurser",
"memberPortalDescription": "Ressurser du har tilgang til i denne organisasjonen",
"memberPortalSortBy": "Sorter etter...",
"memberPortalSortNameAsc": "Navn A-Å",
"memberPortalSortNameDesc": "Navn Å-A",
"memberPortalSortDomainAsc": "Domene A-Å",
"memberPortalSortDomainDesc": "Domene Å-A",
"memberPortalSortEnabledFirst": "Aktivert først",
"memberPortalSortDisabledFirst": "Deaktivert først",
"memberPortalRefresh": "Oppdater",
"memberPortalRefreshResources": "Oppdater ressurser",
"memberPortalFailedToLoad": "Kunne ikke laste inn ressurser",
"memberPortalFailedToLoadDescription": "Kunne ikke laste inn ressurser. Vennligst sjekk tilkoblingen din og prøv igjen.",
"memberPortalUnableToLoad": "Kan ikke laste inn ressurser",
"memberPortalTryAgain": "Prøv igjen",
"memberPortalNoResourcesFound": "Ingen ressurser funnet",
"memberPortalNoResourcesAvailable": "Ingen ressurser tilgjengelig",
"memberPortalNoResourcesMatchSearch": "Ingen ressurser samsvarer med \"{query}\". Prøv å justere søkeordene dine eller fjern søket for å se alle ressurser.",
"memberPortalNoResourcesAccess": "Du har ennå ikke tilgang til noen ressurser. Kontakt administratoren din for å få tilgang til de ressursene du trenger.",
"memberPortalClearSearch": "Fjern søk",
"memberPortalPublicResources": "Offentlige ressurser",
"memberPortalPublicResourcesDescription": "Webapplikasjoner og -tjenester tilgjengelige via nettleser",
"memberPortalCopiedToClipboard": "Kopiert til utklippstavlen",
"memberPortalCopiedUrlDescription": "Ressurs-URL er kopiert til utklippstavlen din.",
"memberPortalOpenResource": "Åpne ressurs",
"memberPortalPrivateResources": "Private ressurser",
"memberPortalPrivateResourcesDescription": "Interne nettverksressurser tilgjengelige via klient",
"memberPortalResourceDetails": "Ressursdetaljer",
"memberPortalMode": "Modus",
"memberPortalDestination": "Destinasjon",
"memberPortalAlias": "Navn",
"memberPortalCopiedAliasDescription": "Ressursalias er kopiert til utklippstavlen din.",
"memberPortalCopiedDestinationDescription": "Ressursdestinasjon er kopiert til utklippstavlen din.",
"memberPortalRequiresClientConnection": "Krever klienttilkobling",
"memberPortalAuthMethods": "Autentiseringsmetoder",
"memberPortalSso": "Enkeltpålogging (SSO)",
"memberPortalPasswordProtected": "Passordbeskyttet",
"memberPortalPinCode": "PIN-kode",
"memberPortalEmailWhitelist": "E-post-hviteliste",
"memberPortalResourceDisabled": "Ressurs deaktivert",
"memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser",
"memberPortalPrevious": "Forrige",
"memberPortalNext": "Neste"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.", "subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.",
"trialBannerMessage": "Uw proefversie verloopt over {countdown}. Upgrade om toegang te behouden.", "trialBannerMessage": "Uw proefversie verloopt over {countdown}. Upgrade om toegang te behouden.",
"trialBannerExpired": "Uw proefperiode is verlopen. Upgrade nu om toegang te herstellen.", "trialBannerExpired": "Uw proefperiode is verlopen. Upgrade nu om toegang te herstellen.",
"billingTrialBannerTitle": "Proefperiode Actief",
"billingTrialBannerDescription": "Je bent momenteel bezig met een gratis proefperiode op het zakelijke niveau. Wanneer de proefperiode eindigt, wordt je account automatisch teruggezet naar de functies en limieten van het Basic-niveau. Upgrade op elk moment om toegang te houden tot de functies van je huidige plan.",
"billingTrialBannerUpgrade": "Nu Upgraden",
"billingTrialBadge": "Gratis Proefversie",
"trialActive": "Gratis proefversie actief", "trialActive": "Gratis proefversie actief",
"trialExpired": "Proefversie verlopen", "trialExpired": "Proefversie verlopen",
"trialHasEnded": "Uw proefperiode is geëindigd.", "trialHasEnded": "Uw proefperiode is geëindigd.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP-adres", "ip": "IP-adres",
"reason": "Reden", "reason": "Reden",
"requestLogs": "Logboeken aanvragen", "requestLogs": "HTTP-aanvraaglogboeken",
"requestAnalytics": "Analytics opvragen", "requestAnalytics": "Analytics opvragen",
"host": "Hostnaam", "host": "Hostnaam",
"location": "Locatie", "location": "Locatie",
"actionLogs": "Actie logs", "actionLogs": "Actie logs",
"sidebarLogsRequest": "Logboeken aanvragen", "sidebarLogsRequest": "HTTP-aanvraaglogboeken",
"sidebarLogsAccess": "Toegang tot logboek", "sidebarLogsAccess": "Toegang tot logboek",
"sidebarLogsAction": "Actie logs", "sidebarLogsAction": "Actie logs",
"logRetention": "Log bewaring", "logRetention": "Log bewaring",
"logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit", "logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit",
"requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie", "requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie",
"requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie", "requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie",
"logRetentionRequestLabel": "Logboekbewaring aanvragen", "logRetentionRequestLabel": "Bewaring van HTTP-aanvraaglogboeken",
"logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden", "logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden",
"logRetentionAccessLabel": "Toegang logboek bewaring", "logRetentionAccessLabel": "Toegang logboek bewaring",
"logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven", "logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.", "httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.",
"httpDestConnectionLogsTitle": "Connectie Logs", "httpDestConnectionLogsTitle": "Connectie Logs",
"httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.", "httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.",
"httpDestRequestLogsTitle": "Logboeken aanvragen", "httpDestRequestLogsTitle": "HTTP-aanvraaglogboeken",
"httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.", "httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.",
"httpDestSaveChanges": "Wijzigingen opslaan", "httpDestSaveChanges": "Wijzigingen opslaan",
"httpDestCreateDestination": "Maak bestemming aan", "httpDestCreateDestination": "Maak bestemming aan",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.", "domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.",
"domainPickerWildcardCertWarningLink": "Meer informatie", "domainPickerWildcardCertWarningLink": "Meer informatie",
"health": "Gezondheid", "health": "Gezondheid",
"domainPendingErrorTitle": "Verificatieprobleem" "domainPendingErrorTitle": "Verificatieprobleem",
"memberPortalTitle": "Bronnen",
"memberPortalDescription": "Bronnen waartoe je toegang hebt binnen deze organisatie",
"memberPortalSortBy": "Sorteren op...",
"memberPortalSortNameAsc": "Naam A-Z",
"memberPortalSortNameDesc": "Naam Z-A",
"memberPortalSortDomainAsc": "Domein A-Z",
"memberPortalSortDomainDesc": "Domein Z-A",
"memberPortalSortEnabledFirst": "Ingeschakeld Eerst",
"memberPortalSortDisabledFirst": "Uitgeschakeld Eerst",
"memberPortalRefresh": "Vernieuwen",
"memberPortalRefreshResources": "Bronnen Vernieuwen",
"memberPortalFailedToLoad": "Fout bij het laden van bronnen",
"memberPortalFailedToLoadDescription": "Fout bij het laden van bronnen. Controleer uw verbinding en probeer het opnieuw.",
"memberPortalUnableToLoad": "Niet in staat om bronnen te laden",
"memberPortalTryAgain": "Probeer Opnieuw",
"memberPortalNoResourcesFound": "Geen Bronnen Gevonden",
"memberPortalNoResourcesAvailable": "Geen Bronnen Beschikbaar",
"memberPortalNoResourcesMatchSearch": "Geen bronnen komen overeen met \"{query}\". Probeer uw zoektermen aan te passen of wis de zoekopdracht om alle bronnen te zien.",
"memberPortalNoResourcesAccess": "Je hebt nog geen toegang tot bronnen. Neem contact op met je beheerder om toegang te krijgen tot de benodigde bronnen.",
"memberPortalClearSearch": "Zoekopdracht Wissen",
"memberPortalPublicResources": "Publieke Bronnen",
"memberPortalPublicResourcesDescription": "Webapplicaties en services toegankelijk via browser",
"memberPortalCopiedToClipboard": "Gekopieerd naar klembord",
"memberPortalCopiedUrlDescription": "Bron URL is naar uw klembord gekopieerd.",
"memberPortalOpenResource": "Bron Openen",
"memberPortalPrivateResources": "Privé Bronnen",
"memberPortalPrivateResourcesDescription": "Interne netwerkbronnen toegankelijk via client",
"memberPortalResourceDetails": "Bron Details",
"memberPortalMode": "Modus",
"memberPortalDestination": "Bestemming",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "Bron alias is naar uw klembord gekopieerd.",
"memberPortalCopiedDestinationDescription": "Bron bestemming is naar uw klembord gekopieerd.",
"memberPortalRequiresClientConnection": "Clientverbinding Vereist",
"memberPortalAuthMethods": "Authenticatiemethoden",
"memberPortalSso": "Single Sign-On (SSO)",
"memberPortalPasswordProtected": "Wachtwoord Beveiligd",
"memberPortalPinCode": "Pincode",
"memberPortalEmailWhitelist": "E-mail whitelist",
"memberPortalResourceDisabled": "Bron Uitgeschakeld",
"memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen",
"memberPortalPrevious": "Vorige",
"memberPortalNext": "Volgende"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.", "subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.",
"trialBannerMessage": "Twój okres próbny wygasa za {countdown}. Uaktualnij, aby zachować dostęp.", "trialBannerMessage": "Twój okres próbny wygasa za {countdown}. Uaktualnij, aby zachować dostęp.",
"trialBannerExpired": "Twój okres próbny wygasł. Uaktualnij teraz, aby przywrócić dostęp.", "trialBannerExpired": "Twój okres próbny wygasł. Uaktualnij teraz, aby przywrócić dostęp.",
"billingTrialBannerTitle": "Bezpłatna wersja próbna aktywna",
"billingTrialBannerDescription": "Obecnie korzystasz z bezpłatnej wersji próbnej na poziomie biznesowym. Po zakończeniu wersji próbnej, Twoje konto automatycznie powróci do funkcji i limitów poziomu Podstawowego. Możesz dokonać uaktualnienia w każdej chwili, aby zachować dostęp do funkcji obecnego planu.",
"billingTrialBannerUpgrade": "Uaktualnij teraz",
"billingTrialBadge": "Bezpłatna wersja próbna",
"trialActive": "Okres próbny aktywny", "trialActive": "Okres próbny aktywny",
"trialExpired": "Okres próbny wygasł", "trialExpired": "Okres próbny wygasł",
"trialHasEnded": "Twój okres próbny dobiegł końca.", "trialHasEnded": "Twój okres próbny dobiegł końca.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Powód", "reason": "Powód",
"requestLogs": "Dzienniki żądań", "requestLogs": "Dzienniki żądań HTTP",
"requestAnalytics": "Żądanie Analityki", "requestAnalytics": "Żądanie Analityki",
"host": "Host", "host": "Host",
"location": "Lokalizacja", "location": "Lokalizacja",
"actionLogs": "Dzienniki działań", "actionLogs": "Dzienniki działań",
"sidebarLogsRequest": "Dzienniki żądań", "sidebarLogsRequest": "Dzienniki żądań HTTP",
"sidebarLogsAccess": "Logi dostępu", "sidebarLogsAccess": "Logi dostępu",
"sidebarLogsAction": "Dzienniki działań", "sidebarLogsAction": "Dzienniki działań",
"logRetention": "Zachowanie dziennika", "logRetention": "Zachowanie dziennika",
"logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je", "logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je",
"requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji", "requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji",
"requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji", "requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji",
"logRetentionRequestLabel": "Zachowanie dziennika żądań", "logRetentionRequestLabel": "Przechowywanie dzienników żądań HTTP",
"logRetentionRequestDescription": "Jak długo zachować dzienniki żądań", "logRetentionRequestDescription": "Jak długo zachować dzienniki żądań",
"logRetentionAccessLabel": "Zachowanie dziennika dostępu", "logRetentionAccessLabel": "Zachowanie dziennika dostępu",
"logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu", "logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.", "httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.",
"httpDestConnectionLogsTitle": "Dzienniki połączeń", "httpDestConnectionLogsTitle": "Dzienniki połączeń",
"httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.", "httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.",
"httpDestRequestLogsTitle": "Dzienniki żądań", "httpDestRequestLogsTitle": "Dzienniki żądań HTTP",
"httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.", "httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.",
"httpDestSaveChanges": "Zapisz zmiany", "httpDestSaveChanges": "Zapisz zmiany",
"httpDestCreateDestination": "Utwórz cel", "httpDestCreateDestination": "Utwórz cel",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.", "domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.",
"domainPickerWildcardCertWarningLink": "Dowiedz się więcej", "domainPickerWildcardCertWarningLink": "Dowiedz się więcej",
"health": "Zdrowie", "health": "Zdrowie",
"domainPendingErrorTitle": "Problem z weryfikacją" "domainPendingErrorTitle": "Problem z weryfikacją",
"memberPortalTitle": "Zasoby",
"memberPortalDescription": "Zasoby, do których masz dostęp w tej organizacji",
"memberPortalSortBy": "Sortuj według...",
"memberPortalSortNameAsc": "Nazwa A-Z",
"memberPortalSortNameDesc": "Nazwa Z-A",
"memberPortalSortDomainAsc": "Domena A-Z",
"memberPortalSortDomainDesc": "Domena Z-A",
"memberPortalSortEnabledFirst": "Włączone najpierw",
"memberPortalSortDisabledFirst": "Wyłączone najpierw",
"memberPortalRefresh": "Odśwież",
"memberPortalRefreshResources": "Odśwież zasoby",
"memberPortalFailedToLoad": "Nie udało się załadować zasobów",
"memberPortalFailedToLoadDescription": "Nie udało się załadować zasobów. Sprawdź połączenie i spróbuj ponownie.",
"memberPortalUnableToLoad": "Nie można załadować zasobów",
"memberPortalTryAgain": "Spróbuj ponownie",
"memberPortalNoResourcesFound": "Nie znaleziono zasobów",
"memberPortalNoResourcesAvailable": "Brak dostępnych zasobów",
"memberPortalNoResourcesMatchSearch": "Żadne zasoby nie pasują do „{query}”. Spróbuj dostosować swoje warunki wyszukiwania lub wyczyść wyszukiwanie, aby zobaczyć wszystkie zasoby.",
"memberPortalNoResourcesAccess": "Nie masz jeszcze dostępu do żadnych zasobów. Skontaktuj się z administratorem, aby uzyskać dostęp do potrzebnych zasobów.",
"memberPortalClearSearch": "Wyczyść wyszukiwanie",
"memberPortalPublicResources": "Publiczne zasoby",
"memberPortalPublicResourcesDescription": "Aplikacje i usługi internetowe dostępne za pośrednictwem przeglądarki",
"memberPortalCopiedToClipboard": "Skopiowano do schowka",
"memberPortalCopiedUrlDescription": "URL zasobu został skopiowany do schowka.",
"memberPortalOpenResource": "Otwórz zasób",
"memberPortalPrivateResources": "Prywatne zasoby",
"memberPortalPrivateResourcesDescription": "Zasoby sieci wewnętrznej dostępne za pośrednictwem klienta",
"memberPortalResourceDetails": "Szczegóły zasobu",
"memberPortalMode": "Tryb",
"memberPortalDestination": "Miejsce docelowe",
"memberPortalAlias": "Pseudonim",
"memberPortalCopiedAliasDescription": "Alias zasobu został skopiowany do schowka.",
"memberPortalCopiedDestinationDescription": "Miejsce docelowe zasobu zostało skopiowane do schowka.",
"memberPortalRequiresClientConnection": "Wymaga połączenia z klientem",
"memberPortalAuthMethods": "Metody uwierzytelniania",
"memberPortalSso": "Jednorazowe logowanie (SSO)",
"memberPortalPasswordProtected": "Chronione hasłem",
"memberPortalPinCode": "Kod PIN",
"memberPortalEmailWhitelist": "Biała lista e-mail",
"memberPortalResourceDisabled": "Zasób wyłączony",
"memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}",
"memberPortalPrevious": "Poprzedni",
"memberPortalNext": "Następny"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.", "subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.",
"trialBannerMessage": "Sua avaliação termina em {countdown}. Faça o upgrade para manter o acesso.", "trialBannerMessage": "Sua avaliação termina em {countdown}. Faça o upgrade para manter o acesso.",
"trialBannerExpired": "Sua avaliação expirou. Faça o upgrade agora para restaurar o acesso.", "trialBannerExpired": "Sua avaliação expirou. Faça o upgrade agora para restaurar o acesso.",
"billingTrialBannerTitle": "Teste Gratuito Ativo",
"billingTrialBannerDescription": "Atualmente, você está em um teste gratuito no nível empresarial. Quando o teste terminar, sua conta reverterá automaticamente para os recursos e limites do nível Básico. Atualize a qualquer momento para manter o acesso aos recursos do seu plano atual.",
"billingTrialBannerUpgrade": "Atualize Agora",
"billingTrialBadge": "Teste Gratuito",
"trialActive": "Avaliação Gratuita Ativa", "trialActive": "Avaliação Gratuita Ativa",
"trialExpired": "Avaliação Expirada", "trialExpired": "Avaliação Expirada",
"trialHasEnded": "Sua avaliação terminou.", "trialHasEnded": "Sua avaliação terminou.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "PI", "ip": "PI",
"reason": "Motivo", "reason": "Motivo",
"requestLogs": "Registro de pedidos", "requestLogs": "Registros de Pedidos HTTP",
"requestAnalytics": "Solicitar análise", "requestAnalytics": "Solicitar análise",
"host": "Servidor", "host": "Servidor",
"location": "Local:", "location": "Local:",
"actionLogs": "Logs de Ações", "actionLogs": "Logs de Ações",
"sidebarLogsRequest": "Registro de pedidos", "sidebarLogsRequest": "Registros de Pedidos HTTP",
"sidebarLogsAccess": "Logs de Acesso", "sidebarLogsAccess": "Logs de Acesso",
"sidebarLogsAction": "Logs de Ações", "sidebarLogsAction": "Logs de Ações",
"logRetention": "Retenção de Log", "logRetention": "Retenção de Log",
"logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los", "logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los",
"requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização", "requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização",
"requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização", "requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização",
"logRetentionRequestLabel": "Solicitar retenção de registro", "logRetentionRequestLabel": "Retenção de Registro de Pedido HTTP",
"logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos", "logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos",
"logRetentionAccessLabel": "Retenção de Log de Acesso", "logRetentionAccessLabel": "Retenção de Log de Acesso",
"logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso", "logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.", "httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.",
"httpDestConnectionLogsTitle": "Logs da conexão", "httpDestConnectionLogsTitle": "Logs da conexão",
"httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.", "httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.",
"httpDestRequestLogsTitle": "Registro de pedidos", "httpDestRequestLogsTitle": "Registros de Pedidos HTTP",
"httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.", "httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.",
"httpDestSaveChanges": "Salvar as alterações", "httpDestSaveChanges": "Salvar as alterações",
"httpDestCreateDestination": "Criar destino", "httpDestCreateDestination": "Criar destino",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.", "domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.",
"domainPickerWildcardCertWarningLink": "Saiba mais", "domainPickerWildcardCertWarningLink": "Saiba mais",
"health": "Saúde", "health": "Saúde",
"domainPendingErrorTitle": "Problema de Verificação" "domainPendingErrorTitle": "Problema de Verificação",
"memberPortalTitle": "Recursos",
"memberPortalDescription": "Recursos aos quais você tem acesso nesta organização",
"memberPortalSortBy": "Ordenar por...",
"memberPortalSortNameAsc": "Nome A-Z",
"memberPortalSortNameDesc": "Nome Z-A",
"memberPortalSortDomainAsc": "Domínio A-Z",
"memberPortalSortDomainDesc": "Domínio Z-A",
"memberPortalSortEnabledFirst": "Habilitados Primeiro",
"memberPortalSortDisabledFirst": "Desabilitados Primeiro",
"memberPortalRefresh": "Atualizar",
"memberPortalRefreshResources": "Atualizar Recursos",
"memberPortalFailedToLoad": "Falha ao carregar recursos",
"memberPortalFailedToLoadDescription": "Falha ao carregar recursos. Por favor, verifique sua conexão e tente novamente.",
"memberPortalUnableToLoad": "Incapaz de Carregar Recursos",
"memberPortalTryAgain": "Tentar Novamente",
"memberPortalNoResourcesFound": "Nenhum Recurso Encontrado",
"memberPortalNoResourcesAvailable": "Nenhum Recurso Disponível",
"memberPortalNoResourcesMatchSearch": "Nenhum recurso corresponde a \"{query}\". Tente ajustar seus termos de pesquisa ou limpe a pesquisa para ver todos os recursos.",
"memberPortalNoResourcesAccess": "Você ainda não tem acesso a nenhum recurso. Entre em contato com seu administrador para obter acesso aos recursos que precisa.",
"memberPortalClearSearch": "Limpar Pesquisa",
"memberPortalPublicResources": "Recursos Públicos",
"memberPortalPublicResourcesDescription": "Aplicações e serviços web acessíveis via navegador",
"memberPortalCopiedToClipboard": "Copiado para a área de transferência",
"memberPortalCopiedUrlDescription": "A URL do recurso foi copiada para sua área de transferência.",
"memberPortalOpenResource": "Abrir Recurso",
"memberPortalPrivateResources": "Recursos Privados",
"memberPortalPrivateResourcesDescription": "Recursos da rede interna acessíveis via cliente",
"memberPortalResourceDetails": "Detalhes do Recurso",
"memberPortalMode": "Modo",
"memberPortalDestination": "Destino",
"memberPortalAlias": "Apelido",
"memberPortalCopiedAliasDescription": "O apelido do recurso foi copiado para sua área de transferência.",
"memberPortalCopiedDestinationDescription": "O destino do recurso foi copiado para sua área de transferência.",
"memberPortalRequiresClientConnection": "Requer Conexão de Cliente",
"memberPortalAuthMethods": "Métodos de Autenticação",
"memberPortalSso": "Logon Único (SSO)",
"memberPortalPasswordProtected": "Protegido por Senha",
"memberPortalPinCode": "Código PIN",
"memberPortalEmailWhitelist": "Lista de E-mails Permitidos",
"memberPortalResourceDisabled": "Recurso Desativado",
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
"memberPortalPrevious": "Anterior",
"memberPortalNext": "Próximo"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.", "subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.",
"trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.", "trialBannerMessage": "Ваш пробный период истекает через {countdown}. Обновите, чтобы сохранить доступ.",
"trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.", "trialBannerExpired": "Ваш пробный период истек. Обновите сейчас, чтобы восстановить доступ.",
"billingTrialBannerTitle": "Бесплатная версия активна",
"billingTrialBannerDescription": "Вы в настоящее время находитесь на бесплатном пробном периоде бизнес-уровня. Когда пробный период закончится, ваш аккаунт автоматически вернётся к функциям и лимитам базового уровня. Обновите в любое время, чтобы сохранить доступ к функциям текущего плана.",
"billingTrialBannerUpgrade": "Обновить сейчас",
"billingTrialBadge": "Бесплатная версия",
"trialActive": "Бесплатный пробный период активен", "trialActive": "Бесплатный пробный период активен",
"trialExpired": "Пробный период истек", "trialExpired": "Пробный период истек",
"trialHasEnded": "Ваш пробный период окончен.", "trialHasEnded": "Ваш пробный период окончен.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Причина", "reason": "Причина",
"requestLogs": "Запросить журналы", "requestLogs": "HTTP Запросы Логи",
"requestAnalytics": "Аналитика запроса", "requestAnalytics": "Аналитика запроса",
"host": "Хост", "host": "Хост",
"location": "Местоположение", "location": "Местоположение",
"actionLogs": "Журнал действий", "actionLogs": "Журнал действий",
"sidebarLogsRequest": "Запросить журналы", "sidebarLogsRequest": "HTTP Запросы Логи",
"sidebarLogsAccess": "Журналы доступа", "sidebarLogsAccess": "Журналы доступа",
"sidebarLogsAction": "Журнал действий", "sidebarLogsAction": "Журнал действий",
"logRetention": "Сохранение журнала", "logRetention": "Сохранение журнала",
"logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их", "logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их",
"requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации", "requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации",
"requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации", "requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации",
"logRetentionRequestLabel": "Запросить сохранение журнала", "logRetentionRequestLabel": "Сохранение HTTP Запросов Лога",
"logRetentionRequestDescription": "Как долго сохранять журналы запросов", "logRetentionRequestDescription": "Как долго сохранять журналы запросов",
"logRetentionAccessLabel": "Хранение журнала доступа", "logRetentionAccessLabel": "Хранение журнала доступа",
"logRetentionAccessDescription": "Как долго сохранять журналы доступа", "logRetentionAccessDescription": "Как долго сохранять журналы доступа",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.", "httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.",
"httpDestConnectionLogsTitle": "Журнал подключений", "httpDestConnectionLogsTitle": "Журнал подключений",
"httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.", "httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.",
"httpDestRequestLogsTitle": "Запросить журналы", "httpDestRequestLogsTitle": "HTTP Запросы Логи",
"httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.", "httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.",
"httpDestSaveChanges": "Сохранить изменения", "httpDestSaveChanges": "Сохранить изменения",
"httpDestCreateDestination": "Создать адрес назначения", "httpDestCreateDestination": "Создать адрес назначения",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.", "domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
"domainPickerWildcardCertWarningLink": "Узнать больше", "domainPickerWildcardCertWarningLink": "Узнать больше",
"health": "Состояние", "health": "Состояние",
"domainPendingErrorTitle": "Проблема с подтверждением" "domainPendingErrorTitle": "Проблема с подтверждением",
"memberPortalTitle": "Ресурсы",
"memberPortalDescription": "Ресурсы, к которым у вас есть доступ в этой организации",
"memberPortalSortBy": "Сортировать по...",
"memberPortalSortNameAsc": "Имя A-Я",
"memberPortalSortNameDesc": "Имя Я-A",
"memberPortalSortDomainAsc": "Домен A-Я",
"memberPortalSortDomainDesc": "Домен Я-A",
"memberPortalSortEnabledFirst": "Включённые сначала",
"memberPortalSortDisabledFirst": "Отключённые сначала",
"memberPortalRefresh": "Обновить",
"memberPortalRefreshResources": "Обновить ресурсы",
"memberPortalFailedToLoad": "Не удалось загрузить ресурсы",
"memberPortalFailedToLoadDescription": "Не удалось загрузить ресурсы. Пожалуйста, проверьте подключение и попробуйте снова.",
"memberPortalUnableToLoad": "Не удалось загрузить ресурсы",
"memberPortalTryAgain": "Попробуйте снова",
"memberPortalNoResourcesFound": "Ресурсы не найдены",
"memberPortalNoResourcesAvailable": "Нет доступных ресурсов",
"memberPortalNoResourcesMatchSearch": "Нет ресурсов, соответствующих \"{query}\". Попробуйте изменить условия поиска или очистить поиск, чтобы увидеть все ресурсы.",
"memberPortalNoResourcesAccess": "У вас пока нет доступа к ресурсам. Свяжитесь с администратором, чтобы получить доступ к нужным вам ресурсам.",
"memberPortalClearSearch": "Очистить поиск",
"memberPortalPublicResources": "Публичные ресурсы",
"memberPortalPublicResourcesDescription": "Веб-приложения и сервисы, доступные через браузер",
"memberPortalCopiedToClipboard": "Скопировано в буфер обмена",
"memberPortalCopiedUrlDescription": "URL ресурса был скопирован в ваш буфер обмена.",
"memberPortalOpenResource": "Открыть ресурс",
"memberPortalPrivateResources": "Приватные ресурсы",
"memberPortalPrivateResourcesDescription": "Ресурсы внутренней сети, доступные через клиент",
"memberPortalResourceDetails": "Детали ресурса",
"memberPortalMode": "Режим",
"memberPortalDestination": "Назначение",
"memberPortalAlias": "Псевдоним",
"memberPortalCopiedAliasDescription": "Псевдоним ресурса был скопирован в ваш буфер обмена.",
"memberPortalCopiedDestinationDescription": "Назначение ресурса было скопировано в ваш буфер обмена.",
"memberPortalRequiresClientConnection": "Требуется подключение клиента",
"memberPortalAuthMethods": "Методы аутентификации",
"memberPortalSso": "Единый вход (SSO)",
"memberPortalPasswordProtected": "Защищено паролем",
"memberPortalPinCode": "PIN-код",
"memberPortalEmailWhitelist": "Белый список email",
"memberPortalResourceDisabled": "Ресурс отключён",
"memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов",
"memberPortalPrevious": "Предыдущий",
"memberPortalNext": "Следующий"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.", "subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.",
"trialBannerMessage": "Deneme süreniz {countdown} içinde sona eriyor. Erişimi sürdürmek için yükseltin.", "trialBannerMessage": "Deneme süreniz {countdown} içinde sona eriyor. Erişimi sürdürmek için yükseltin.",
"trialBannerExpired": "Deneme süreniz sona erdi. Erişimi geri yüklemek için şimdi yükseltin.", "trialBannerExpired": "Deneme süreniz sona erdi. Erişimi geri yüklemek için şimdi yükseltin.",
"billingTrialBannerTitle": "Ücretsiz Deneme Aktif",
"billingTrialBannerDescription": "Şu anda iş seviyesi için ücretsiz deneme sürümündesiniz. Deneme süresi sona erdiğinde, hesabınız otomatik olarak Temel seviye özelliklerine ve limitlerine geri dönecektir. Mevcut planınızın özelliklerine erişimi sürdürmek için istediğiniz zaman yükseltin.",
"billingTrialBannerUpgrade": "Şimdi Yükselt",
"billingTrialBadge": "Ücretsiz Deneme",
"trialActive": "Ücretsiz Deneme Aktif", "trialActive": "Ücretsiz Deneme Aktif",
"trialExpired": "Deneme Süresi Doldu", "trialExpired": "Deneme Süresi Doldu",
"trialHasEnded": "Deneme süreniz sona erdi.", "trialHasEnded": "Deneme süreniz sona erdi.",
@@ -2656,19 +2660,19 @@
"noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok", "noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok",
"ip": "IP", "ip": "IP",
"reason": "Sebep", "reason": "Sebep",
"requestLogs": "İstek Günlükleri", "requestLogs": "HTTP İstek Günlükleri",
"requestAnalytics": "İstek Analizi", "requestAnalytics": "İstek Analizi",
"host": "Sunucu", "host": "Sunucu",
"location": "Konum", "location": "Konum",
"actionLogs": "Eylem Günlükleri", "actionLogs": "Eylem Günlükleri",
"sidebarLogsRequest": "İstek Günlükleri", "sidebarLogsRequest": "HTTP İstek Günlükleri",
"sidebarLogsAccess": "Erişim Günlükleri", "sidebarLogsAccess": "Erişim Günlükleri",
"sidebarLogsAction": "Eylem Günlükleri", "sidebarLogsAction": "Eylem Günlükleri",
"logRetention": "Kayıt Saklama", "logRetention": "Kayıt Saklama",
"logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın", "logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın",
"requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin", "requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin",
"requestAnalyticsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek analizlerini görüntüleyin.", "requestAnalyticsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek analizlerini görüntüleyin.",
"logRetentionRequestLabel": "İstek Günlüğü Saklama", "logRetentionRequestLabel": "HTTP İstek Günlüğü Saklama",
"logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle", "logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle",
"logRetentionAccessLabel": "Erişim Günlüğü Saklama", "logRetentionAccessLabel": "Erişim Günlüğü Saklama",
"logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle", "logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle",
@@ -3130,7 +3134,7 @@
"httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.", "httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.",
"httpDestConnectionLogsTitle": "Bağlantı Kayıtları", "httpDestConnectionLogsTitle": "Bağlantı Kayıtları",
"httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.", "httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.",
"httpDestRequestLogsTitle": "İstek Kayıtları", "httpDestRequestLogsTitle": "HTTP İstek Günlükleri",
"httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.", "httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.",
"httpDestSaveChanges": "Değişiklikleri Kaydet", "httpDestSaveChanges": "Değişiklikleri Kaydet",
"httpDestCreateDestination": "Hedef Oluştur", "httpDestCreateDestination": "Hedef Oluştur",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.", "domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.",
"domainPickerWildcardCertWarningLink": "Daha fazla bilgi", "domainPickerWildcardCertWarningLink": "Daha fazla bilgi",
"health": "Sağlık", "health": "Sağlık",
"domainPendingErrorTitle": "Doğrulama Sorunu" "domainPendingErrorTitle": "Doğrulama Sorunu",
"memberPortalTitle": "Kaynaklar",
"memberPortalDescription": "Bu organizasyondaki erişiminiz olan kaynaklar",
"memberPortalSortBy": "Şuna göre sırala...",
"memberPortalSortNameAsc": "İsim A-Z",
"memberPortalSortNameDesc": "İsim Z-A",
"memberPortalSortDomainAsc": "Alan A-Z",
"memberPortalSortDomainDesc": "Alan Z-A",
"memberPortalSortEnabledFirst": "İlk Etkinleştirilenler",
"memberPortalSortDisabledFirst": "İlk Devre Dışı Bırakılanlar",
"memberPortalRefresh": "Yenile",
"memberPortalRefreshResources": "Kaynakları Yenile",
"memberPortalFailedToLoad": "Kaynaklar yüklenemedi",
"memberPortalFailedToLoadDescription": "Kaynaklar yüklenemedi. Lütfen bağlantınızı kontrol edin ve tekrar deneyin.",
"memberPortalUnableToLoad": "Kaynaklar Yüklenemiyor",
"memberPortalTryAgain": "Tekrar Dene",
"memberPortalNoResourcesFound": "Hiçbir Kaynak Bulunamadı",
"memberPortalNoResourcesAvailable": "Uygun Kaynak Yok",
"memberPortalNoResourcesMatchSearch": "Hiçbir kaynak \"{query}\" ile eşleşmiyor. Arama terimlerinizi değiştirerek veya tüm kaynakları görmek için aramayı temizleyerek deneyin.",
"memberPortalNoResourcesAccess": "Henüz herhangi bir kaynağa erişiminiz yok. İhtiyacınız olan kaynaklara erişim sağlamak için yöneticinizle iletişime geçin.",
"memberPortalClearSearch": "Aramayı Temizle",
"memberPortalPublicResources": "Genel Kaynaklar",
"memberPortalPublicResourcesDescription": "Tarayıcı üzerinden erişilebilen web uygulamaları ve hizmetler",
"memberPortalCopiedToClipboard": "Panoya kopyalandı",
"memberPortalCopiedUrlDescription": "Kaynak URL'si panonuza kopyalandı.",
"memberPortalOpenResource": "Kaynağı Aç",
"memberPortalPrivateResources": "Özel Kaynaklar",
"memberPortalPrivateResourcesDescription": "İstemci üzerinden erişilebilen dahili ağ kaynakları",
"memberPortalResourceDetails": "Kaynak Detayları",
"memberPortalMode": "Mod",
"memberPortalDestination": "Hedef",
"memberPortalAlias": "Takma İsim",
"memberPortalCopiedAliasDescription": "Kaynak takma adı panonuza kopyalandı.",
"memberPortalCopiedDestinationDescription": "Kaynak hedefi panonuza kopyalandı.",
"memberPortalRequiresClientConnection": "İstemci Bağlantısı Gerektirir",
"memberPortalAuthMethods": "Kimlik Doğrulama Yöntemleri",
"memberPortalSso": "Tek Oturum Açma (SSO)",
"memberPortalPasswordProtected": "Parola ile Korunan",
"memberPortalPinCode": "PIN Kodu",
"memberPortalEmailWhitelist": "E-posta Beyaz Listesi",
"memberPortalResourceDisabled": "Kaynak Devre Dışı",
"memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor",
"memberPortalPrevious": "Önceki",
"memberPortalNext": "Sonraki"
} }

View File

@@ -25,6 +25,10 @@
"subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。", "subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。",
"trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。", "trialBannerMessage": "您的试用将在 {countdown} 到期。升级以保持访问。",
"trialBannerExpired": "您的试用已到期。立即升级以恢复访问。", "trialBannerExpired": "您的试用已到期。立即升级以恢复访问。",
"billingTrialBannerTitle": "免费试用激活中",
"billingTrialBannerDescription": "您目前正在商用层进行免费试用。试用结束后,您的账户将自动回到基础层功能和限制。可随时升级以保持当前计划的功能访问。",
"billingTrialBannerUpgrade": "立即升级",
"billingTrialBadge": "免费试用",
"trialActive": "免费试用中", "trialActive": "免费试用中",
"trialExpired": "试用到期", "trialExpired": "试用到期",
"trialHasEnded": "您的试用已结束。", "trialHasEnded": "您的试用已结束。",
@@ -2668,7 +2672,7 @@
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志", "logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
"requestLogsDescription": "查看此机构资源的详细请求日志", "requestLogsDescription": "查看此机构资源的详细请求日志",
"requestAnalyticsDescription": "查看此机构资源的详细请求分析", "requestAnalyticsDescription": "查看此机构资源的详细请求分析",
"logRetentionRequestLabel": "请求日志保留", "logRetentionRequestLabel": "HTTP 请求日志保留",
"logRetentionRequestDescription": "保留请求日志的时间", "logRetentionRequestDescription": "保留请求日志的时间",
"logRetentionAccessLabel": "访问日志保留", "logRetentionAccessLabel": "访问日志保留",
"logRetentionAccessDescription": "保留访问日志的时间", "logRetentionAccessDescription": "保留访问日志的时间",
@@ -3204,5 +3208,48 @@
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。", "domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
"domainPickerWildcardCertWarningLink": "了解更多", "domainPickerWildcardCertWarningLink": "了解更多",
"health": "健康", "health": "健康",
"domainPendingErrorTitle": "验证问题" "domainPendingErrorTitle": "验证问题",
"memberPortalTitle": "资源",
"memberPortalDescription": "您在此组织中可以访问的资源",
"memberPortalSortBy": "排序依据……",
"memberPortalSortNameAsc": "名称 A-Z",
"memberPortalSortNameDesc": "名称 Z-A",
"memberPortalSortDomainAsc": "域名 A-Z",
"memberPortalSortDomainDesc": "域名 Z-A",
"memberPortalSortEnabledFirst": "启用优先",
"memberPortalSortDisabledFirst": "禁用优先",
"memberPortalRefresh": "刷新",
"memberPortalRefreshResources": "刷新资源",
"memberPortalFailedToLoad": "加载资源失败",
"memberPortalFailedToLoadDescription": "加载资源失败。请检查您的连接并再试一次。",
"memberPortalUnableToLoad": "无法加载资源",
"memberPortalTryAgain": "再试一次",
"memberPortalNoResourcesFound": "找不到资源",
"memberPortalNoResourcesAvailable": "无可用资源",
"memberPortalNoResourcesMatchSearch": "没有与\"{query}\"匹配的资源。尝试调整您的搜索词或清除搜索以查看所有资源。",
"memberPortalNoResourcesAccess": "您尚无访问任何资源的权限。请联系您的管理员获取所需资源的访问权限。",
"memberPortalClearSearch": "清除搜索",
"memberPortalPublicResources": "公共资源",
"memberPortalPublicResourcesDescription": "通过浏览器可访问的网络应用和服务",
"memberPortalCopiedToClipboard": "已复制到剪贴板",
"memberPortalCopiedUrlDescription": "资源 URL 已复制到您的剪贴板。",
"memberPortalOpenResource": "打开资源",
"memberPortalPrivateResources": "私有资源",
"memberPortalPrivateResourcesDescription": "通过客户端可访问的内部网络资源",
"memberPortalResourceDetails": "资源详情",
"memberPortalMode": "模式",
"memberPortalDestination": "目标",
"memberPortalAlias": "别名",
"memberPortalCopiedAliasDescription": "资源别名已复制到您的剪贴板。",
"memberPortalCopiedDestinationDescription": "资源目的地已复制到您的剪贴板。",
"memberPortalRequiresClientConnection": "需要客户端连接",
"memberPortalAuthMethods": "身份验证方法",
"memberPortalSso": "单一登录 (SSO)",
"memberPortalPasswordProtected": "密码保护",
"memberPortalPinCode": "PIN 码",
"memberPortalEmailWhitelist": "电子邮件白名单",
"memberPortalResourceDisabled": "资源已禁用",
"memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源",
"memberPortalPrevious": "上一页",
"memberPortalNext": "下一页"
} }

View File

@@ -87,7 +87,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export const primaryDb = db.$primary; export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];

View File

@@ -1,5 +1,6 @@
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import * as schema from "./schema/schema"; import * as schema from "./schema/schema";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
@@ -11,8 +12,69 @@ export const exists = checkFileExists(location);
bootstrapVolume(); bootstrapVolume();
/**
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
* execution, freeing native sqlite3_stmt memory deterministically instead
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
* WARNING: Finalizes after first execution — incompatible with drizzle's
* reusable .prepare() builders. No such usage exists in this codebase.
*/
function autoFinalizeStatement(
stmt: BetterSqlite3.Statement
): BetterSqlite3.Statement {
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
return function (this: any, ...args: any[]) {
try {
return fn.apply(this, args);
} finally {
try {
// finalize() exists on the native Statement at runtime but
// is missing from @types/better-sqlite3.
(stmt as any).finalize();
} catch {
// Already finalized — harmless
}
}
} as unknown as T;
};
stmt.run = wrapExec(stmt.run);
stmt.get = wrapExec(stmt.get);
stmt.all = wrapExec(stmt.all);
return stmt;
}
function createDb() { function createDb() {
const sqlite = new Database(location); const sqlite = new Database(location);
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping).
sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL");
}
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
// retry loops that accumulate memory.
sqlite.pragma("busy_timeout = 5000");
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
// TraefikConfigManager JOINs that block the event loop.
sqlite.pragma("cache_size = -65536");
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
// reducing event-loop blocking.
sqlite.pragma("mmap_size = 268435456");
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
// first use, preventing sqlite3_stmt accumulation between GC cycles.
const originalPrepare = sqlite.prepare.bind(sqlite);
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
return autoFinalizeStatement(originalPrepare(source));
};
return DrizzleSqlite(sqlite, { return DrizzleSqlite(sqlite, {
schema schema
}); });
@@ -23,7 +85,7 @@ export default db;
export const primaryDb = db; export const primaryDb = db;
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];
export const DB_TYPE: "pg" | "sqlite" = "sqlite"; export const DB_TYPE: "pg" | "sqlite" = "sqlite";
function checkFileExists(filePath: string): boolean { function checkFileExists(filePath: string): boolean {

View File

@@ -361,7 +361,7 @@ export async function updateClientResources(
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
if (resourceData.mode === "host" || resourceData.mode === "http") { if (resourceData.mode === "host" || resourceData.mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
} }
let domainInfo: let domainInfo:

View File

@@ -25,9 +25,162 @@ import { tierMatrix } from "./billing/tierMatrix";
export async function calculateUserClientsForOrgs( export async function calculateUserClientsForOrgs(
userId: string, userId: string,
trx?: Transaction trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
const execute = async (transaction: Transaction) => { const execute = async (transaction: Transaction | typeof db) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<
string,
typeof roles.$inferSelect | null
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrgOlmKey = (orgId: string, olmId: string) =>
`${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (
roleId: number,
clientId: number
) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user // Get all OLMs for this user
const userOlms = await transaction const userOlms = await transaction
.select() .select()
@@ -54,7 +207,9 @@ export async function calculateUserClientsForOrgs(
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId)); .where(eq(userOrgs.userId, userId));
const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))]; const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map< const orgIdToRoleRows = new Map<
string, string,
(typeof userOrgRoleRows)[0][] (typeof userOrgRoleRows)[0][]
@@ -64,6 +219,13 @@ export async function calculateUserClientsForOrgs(
list.push(r); list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list); orgIdToRoleRows.set(r.userOrgs.orgId, list);
} }
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in // For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) { for (const olm of userOlms) {
@@ -71,10 +233,7 @@ export async function calculateUserClientsForOrgs(
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs; const userOrg = roleRowsForOrg[0].userOrgs;
const [org] = await transaction const org = await getOrg(orgId);
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) { if (!org) {
logger.warn( logger.warn(
@@ -91,11 +250,7 @@ export async function calculateUserClientsForOrgs(
} }
// Get admin role for this org (needed for access grants) // Get admin role for this org (needed for access grants)
const [adminRole] = await transaction const adminRole = await getAdminRole(orgId);
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) { if (!adminRole) {
logger.warn( logger.warn(
@@ -105,64 +260,50 @@ export async function calculateUserClientsForOrgs(
} }
// Check if a client already exists for this OLM+user+org combination // Check if a client already exists for this OLM+user+org combination
const [existingClient] = await transaction const existingClient = await getExistingClient(
.select() orgId,
.from(clients) olm.olmId
.where( );
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olm.olmId)
)
)
.limit(1);
if (existingClient) { if (existingClient) {
// Ensure admin role has access to the client // Ensure admin role has access to the client
const [existingRoleClient] = await transaction const hasRoleAccess = await hasRoleClientAccess(
.select() adminRole.roleId,
.from(roleClients) existingClient.clientId
.where( );
and(
eq(roleClients.roleId, adminRole.roleId),
eq(
roleClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
if (!existingRoleClient) { if (!hasRoleAccess) {
await transaction.insert(roleClients).values({ await transaction.insert(roleClients).values({
roleId: adminRole.roleId, roleId: adminRole.roleId,
clientId: existingClient.clientId clientId: existingClient.clientId
}); });
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug( logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` `Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
); );
} }
// Ensure user has access to the client // Ensure user has access to the client
const [existingUserClient] = await transaction const hasUserAccess = await hasUserClientAccess(
.select() userId,
.from(userClients) existingClient.clientId
.where( );
and(
eq(userClients.userId, userId),
eq(
userClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
if (!existingUserClient) { if (!hasUserAccess) {
await transaction.insert(userClients).values({ await transaction.insert(userClients).values({
userId, userId,
clientId: existingClient.clientId clientId: existingClient.clientId
}); });
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug( logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` `Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
); );
@@ -175,7 +316,7 @@ export async function calculateUserClientsForOrgs(
} }
// Get exit nodes for this org // Get exit nodes for this org
const exitNodesList = await listExitNodes(orgId); const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) { if (exitNodesList.length === 0) {
logger.warn( logger.warn(
@@ -206,14 +347,11 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId); const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await isLicensedOrSubscribed( const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
userOrg.orgId,
tierMatrix.deviceApprovals
);
const requireApproval = const requireApproval =
build !== "oss" && build !== "oss" &&
isOrgLicensed && isOrgLicensed &&
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval); orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = { const newClientData: InferInsertModel<typeof clients> = {
userId, userId,
@@ -232,6 +370,10 @@ export async function calculateUserClientsForOrgs(
.insert(clients) .insert(clients)
.values(newClientData) .values(newClientData)
.returning(); .returning();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request // create approval request
if (requireApproval) { if (requireApproval) {
@@ -257,12 +399,20 @@ export async function calculateUserClientsForOrgs(
roleId: adminRole.roleId, roleId: adminRole.roleId,
clientId: newClient.clientId clientId: newClient.clientId
}); });
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client // Grant user access to the client
await transaction.insert(userClients).values({ await transaction.insert(userClients).values({
userId, userId,
clientId: newClient.clientId clientId: newClient.clientId
}); });
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug( logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user` `Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
@@ -287,7 +437,7 @@ export async function calculateUserClientsForOrgs(
async function cleanupOrphanedClients( async function cleanupOrphanedClients(
userId: string, userId: string,
trx: Transaction, trx: Transaction | typeof db,
userOrgIds: string[] = [] userOrgIds: string[] = []
): Promise<void> { ): Promise<void> {
// Find all OLM clients for this user that should be deleted // Find all OLM clients for this user that should be deleted

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.18.2"; export const APP_VERSION = "1.18.3";
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);

View File

@@ -6,6 +6,7 @@ import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver"; import semver from "semver";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
import { lockManager } from "#dynamic/lib/lock";
interface IPRange { interface IPRange {
start: bigint; start: bigint;
@@ -327,120 +328,146 @@ export async function getNextAvailableClientSubnet(
orgId: string, orgId: string,
transaction: Transaction | typeof db = db transaction: Transaction | typeof db = db
): Promise<string> { ): Promise<string> {
const [org] = await transaction return await lockManager.withLock(
.select() `client-subnet-allocation:${orgId}`,
.from(orgs) async () => {
.where(eq(orgs.orgId, orgId)); const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) { if (!org) {
throw new Error(`Organization with ID ${orgId} not found`); throw new Error(`Organization with ID ${orgId} not found`);
} }
if (!org.subnet) { if (!org.subnet) {
throw new Error(`Organization with ID ${orgId} has no subnet defined`); throw new Error(
} `Organization with ID ${orgId} has no subnet defined`
);
}
const existingAddressesSites = await transaction const existingAddressesSites = await transaction
.select({ .select({
address: sites.address address: sites.address
}) })
.from(sites) .from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); .where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await transaction const existingAddressesClients = await transaction
.select({ .select({
address: clients.subnet address: clients.subnet
}) })
.from(clients) .from(clients)
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId))); .where(
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
);
const addresses = [ const addresses = [
...existingAddressesSites.map( ...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32` (site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map( ...existingAddressesClients.map(
(client) => `${client.address.split("/")}/32` (client) => `${client.address.split("/")}/32`
) )
].filter((address) => address !== null) as string[]; ].filter((address) => address !== null) as string[];
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) { if (!subnet) {
throw new Error("No available subnets remaining in space"); throw new Error("No available subnets remaining in space");
} }
return subnet; return subnet;
}
);
} }
export async function getNextAvailableAliasAddress( export async function getNextAvailableAliasAddress(
orgId: string orgId: string,
trx: Transaction | typeof db = db
): Promise<string> { ): Promise<string> {
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); return await lockManager.withLock(
`alias-address-allocation:${orgId}`,
async () => {
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) { if (!org) {
throw new Error(`Organization with ID ${orgId} not found`); throw new Error(`Organization with ID ${orgId} not found`);
} }
if (!org.subnet) { if (!org.subnet) {
throw new Error(`Organization with ID ${orgId} has no subnet defined`); throw new Error(
} `Organization with ID ${orgId} has no subnet defined`
);
}
if (!org.utilitySubnet) { if (!org.utilitySubnet) {
throw new Error( throw new Error(
`Organization with ID ${orgId} has no utility subnet defined` `Organization with ID ${orgId} has no utility subnet defined`
); );
} }
const existingAddresses = await db const existingAddresses = await trx
.select({ .select({
aliasAddress: siteResources.aliasAddress aliasAddress: siteResources.aliasAddress
}) })
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
isNotNull(siteResources.aliasAddress), isNotNull(siteResources.aliasAddress),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
); );
const addresses = [ const addresses = [
...existingAddresses.map( ...existingAddresses.map(
(site) => `${site.aliasAddress?.split("/")[0]}/32` (site) => `${site.aliasAddress?.split("/")[0]}/32`
), ),
// reserve a /29 for the dns server and other stuff // reserve a /29 for the dns server and other stuff
`${org.utilitySubnet.split("/")[0]}/29` `${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[]; ].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet); let subnet = findNextAvailableCidr(
if (!subnet) { addresses,
throw new Error("No available subnets remaining in space"); 32,
} org.utilitySubnet
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// remove the cidr // remove the cidr
subnet = subnet.split("/")[0]; subnet = subnet.split("/")[0];
return subnet; return subnet;
}
);
} }
export async function getNextAvailableOrgSubnet(): Promise<string> { export async function getNextAvailableOrgSubnet(): Promise<string> {
const existingAddresses = await db return await lockManager.withLock("org-subnet-allocation", async () => {
.select({ const existingAddresses = await db
subnet: orgs.subnet .select({
}) subnet: orgs.subnet
.from(orgs) })
.where(isNotNull(orgs.subnet)); .from(orgs)
.where(isNotNull(orgs.subnet));
const addresses = existingAddresses.map((org) => org.subnet!); const addresses = existingAddresses.map((org) => org.subnet!);
const subnet = findNextAvailableCidr( const subnet = findNextAvailableCidr(
addresses, addresses,
config.getRawConfig().orgs.block_size, config.getRawConfig().orgs.block_size,
config.getRawConfig().orgs.subnet_group config.getRawConfig().orgs.subnet_group
); );
if (!subnet) { if (!subnet) {
throw new Error("No available subnets remaining in space"); throw new Error("No available subnets remaining in space");
} }
return subnet; return subnet;
});
} }
export function generateRemoteSubnets( export function generateRemoteSubnets(
@@ -478,7 +505,12 @@ export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources return allSiteResources
.filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http"))) .filter(
(sr) =>
sr.aliasAddress &&
((sr.alias && sr.mode == "host") ||
(sr.fullDomain && sr.mode == "http"))
)
.map((sr) => ({ .map((sr) => ({
alias: sr.alias || sr.fullDomain, alias: sr.alias || sr.fullDomain,
aliasAddress: sr.aliasAddress aliasAddress: sr.aliasAddress

View File

@@ -24,8 +24,11 @@ export async function getCachedStatusHistory(
return cached; return cached;
} }
const nowSec = Math.floor(Date.now() / 1000); // Anchor to UTC midnight so the query window aligns with stable calendar days
const startSec = nowSec - days * 86400; const utcToday = new Date();
utcToday.setUTCHours(0, 0, 0, 0);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const startSec = todayMidnightSec - days * 86400;
const events = await logsDb const events = await logsDb
.select() .select()
@@ -110,11 +113,18 @@ export function computeBuckets(
days: number days: number
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
// Anchor bucket boundaries to UTC midnight so dates are stable calendar days
// and don't drift as the cache expires and is recomputed
const utcToday = new Date();
utcToday.setUTCHours(0, 0, 0, 0);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const buckets: StatusHistoryDayBucket[] = []; const buckets: StatusHistoryDayBucket[] = [];
let totalDowntime = 0; let totalDowntime = 0;
for (let d = 0; d < days; d++) { for (let d = 0; d < days; d++) {
const dayStartSec = nowSec - (days - d) * 86400; const dayStartSec = todayMidnightSec - (days - 1 - d) * 86400;
const dayEndSec = dayStartSec + 86400; const dayEndSec = dayStartSec + 86400;
const dayEvents = events.filter( const dayEvents = events.filter(

View File

@@ -485,6 +485,133 @@ async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
} }
} }
async function storeCertForDomain(
domain: string,
certPem: string,
keyPem: string,
validatedX509: crypto.X509Certificate
): Promise<void> {
const wildcard = domain.startsWith("*.");
const existing = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.limit(1);
let oldCertPem: string | null = null;
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decrypt(
existing[0].certFile,
config.getRawConfig().server.secret!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
return;
}
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} catch (err) {
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
let expiresAt: number | null = null;
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
);
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain);
if (domainId) {
logger.debug(
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
);
}
if (existing.length > 0) {
logger.debug(
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
logger.debug(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
logger.debug(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
function findAcmeJsonFiles(dirPath: string): string[] { function findAcmeJsonFiles(dirPath: string): string[] {
const results: string[] = []; const results: string[] = [];
let entries: fs.Dirent[]; let entries: fs.Dirent[];
@@ -500,7 +627,30 @@ function findAcmeJsonFiles(dirPath: string): string[] {
const fullPath = path.join(dirPath, entry.name); const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
results.push(...findAcmeJsonFiles(fullPath)); results.push(...findAcmeJsonFiles(fullPath));
} else if (entry.isFile() && entry.name === "acme.json") { } else if (entry.isFile()) {
// check if it is a json file
if (entry.name.endsWith(".json")) {
let raw: string;
try {
raw = fs.readFileSync(fullPath, "utf8");
} catch (err) {
logger.warn(
`acmeCertSync: could not read file "${fullPath}": ${err}`
);
continue;
}
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch (err) {
logger.warn(
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
);
continue;
}
}
results.push(fullPath); results.push(fullPath);
} }
} }
@@ -552,18 +702,16 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
} }
for (const cert of allCerts) { for (const cert of allCerts) {
const domain = cert?.domain?.main; const mainDomain = cert?.domain?.main;
if (!domain || typeof domain !== "string") { if (!mainDomain || typeof mainDomain !== "string") {
logger.debug(`acmeCertSync: skipping cert with missing domain`); logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue; continue;
} }
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
if (!cert.certificate || !cert.key) { if (!cert.certificate || !cert.key) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field` `acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
); );
continue; continue;
} }
@@ -575,14 +723,14 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
keyPem = Buffer.from(cert.key, "base64").toString("utf8"); keyPem = Buffer.from(cert.key, "base64").toString("utf8");
} catch (err) { } catch (err) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}` `acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
); );
continue; continue;
} }
if (!certPem.trim() || !keyPem.trim()) { if (!certPem.trim() || !keyPem.trim()) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode` `acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
); );
continue; continue;
} }
@@ -593,7 +741,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
const firstCertPemForValidation = extractFirstCert(certPem); const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) { if (!firstCertPemForValidation) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found` `acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
); );
continue; continue;
} }
@@ -605,7 +753,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
); );
} catch (err) { } catch (err) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}` `acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
); );
continue; continue;
} }
@@ -615,139 +763,40 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
crypto.createPrivateKey(keyPem); crypto.createPrivateKey(keyPem);
} catch (err) { } catch (err) {
logger.debug( logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}` `acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
); );
continue; continue;
} }
// Check if cert already exists in DB // Collect all domains covered by this cert: main + every SAN.
const existing = await db // Each domain gets its own row in the certificates table so that
.select() // lookups by any hostname on the cert succeed independently.
.from(certificates) const allDomains = new Set<string>([mainDomain]);
.where(and(eq(certificates.domain, domain))) if (Array.isArray(cert.domain?.sans)) {
.limit(1); for (const san of cert.domain.sans) {
if (typeof san === "string" && san.trim()) {
let oldCertPem: string | null = null; allDomains.add(san.trim());
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decrypt(
existing[0].certFile,
config.getRawConfig().server.secret!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
// logger.debug(
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
// );
continue;
} }
// Cert has changed; capture old values so we can send a correct
// update message to the newt after the DB write.
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} catch (err) {
// Decryption failure means we should proceed with the update
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
} }
} }
// Parse cert expiry from the validated X.509 certificate logger.debug(
let expiresAt: number | null = null; `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
); );
const encryptedKey = encrypt(
keyPem,
config.getRawConfig().server.secret!
);
const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain); for (const domain of allDomains) {
if (domainId) { try {
logger.debug( await storeCertForDomain(
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"` domain,
); certPem,
} else { keyPem,
logger.debug( validatedX509
`acmeCertSync: no matching domain record found for cert domain "${domain}"` );
); } catch (err) {
} logger.error(
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
if (existing.length > 0) { );
logger.debug( }
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
logger.debug(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
logger.debug(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
// For a brand-new cert, push to any SSL resources that were waiting for it
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
} }
} }
} }

View File

@@ -29,7 +29,10 @@ import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import { sendAlertWebhook } from "./sendAlertWebhook"; import { sendAlertWebhook } from "./sendAlertWebhook";
import { sendAlertEmail } from "./sendAlertEmail"; import { sendAlertEmail } from "./sendAlertEmail";
import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types"; import {
AlertContext,
WebhookAlertConfig
} from "@server/routers/alertRule/types";
/** /**
* Core alert processing pipeline. * Core alert processing pipeline.
@@ -99,7 +102,10 @@ export async function processAlerts(context: AlertContext): Promise<void> {
baseConditions, baseConditions,
or( or(
eq(alertRules.allHealthChecks, true), eq(alertRules.allHealthChecks, true),
eq(alertHealthChecks.healthCheckId, context.healthCheckId) eq(
alertHealthChecks.healthCheckId,
context.healthCheckId
)
) )
) )
); );
@@ -208,14 +214,19 @@ async function processRule(
for (const action of emailActions) { for (const action of emailActions) {
try { try {
const recipients = await resolveEmailRecipients(action.emailActionId); const recipients = await resolveEmailRecipients(
action.emailActionId
);
if (recipients.length > 0) { if (recipients.length > 0) {
await sendAlertEmail(recipients, context); await sendAlertEmail(recipients, context);
await db await db
.update(alertEmailActions) .update(alertEmailActions)
.set({ lastSentAt: now }) .set({ lastSentAt: now })
.where( .where(
eq(alertEmailActions.emailActionId, action.emailActionId) eq(
alertEmailActions.emailActionId,
action.emailActionId
)
); );
} }
} catch (err) { } catch (err) {
@@ -269,7 +280,7 @@ async function processRule(
) )
); );
} catch (err) { } catch (err) {
logger.error( logger.warn(
`processAlerts: failed to send alert webhook for action ${action.webhookActionId}`, `processAlerts: failed to send alert webhook for action ${action.webhookActionId}`,
err err
); );
@@ -289,7 +300,9 @@ async function processRule(
* - All users in a role (by `roleId`, resolved via `userOrgRoles`) * - All users in a role (by `roleId`, resolved via `userOrgRoles`)
* - Direct external email addresses * - Direct external email addresses
*/ */
async function resolveEmailRecipients(emailActionId: number): Promise<string[]> { async function resolveEmailRecipients(
emailActionId: number
): Promise<string[]> {
const rows = await db const rows = await db
.select() .select()
.from(alertEmailRecipients) .from(alertEmailRecipients)

View File

@@ -236,15 +236,43 @@ interface TemplateContext {
} }
/** /**
* Render a body template with {{event}}, {{timestamp}}, {{status}}, and * Render a body template with {{event}}, {{timestamp}}, {{status}}, {{data}},
* {{data}} placeholders, mirroring the logic in HttpLogDestination. * and individual data-field placeholders (e.g. {{orgId}}, {{siteId}}, …).
* *
* {{data}} is replaced first (as raw JSON) so that any literal "{{…}}" * Replacement order:
* strings inside data values are not re-expanded. * 1. {{data}} → raw JSON of the full data object (prevents re-expansion of
* nested values that might look like placeholders).
* 2. Top-level scalar fields from data (string values are JSON-escaped;
* numbers and booleans are rendered as-is). Unknown placeholders are
* left untouched.
* 3. The fixed top-level keys: event, timestamp, status.
*/ */
function renderTemplate(template: string, ctx: TemplateContext): string { function renderTemplate(template: string, ctx: TemplateContext): string {
const rendered = template // Step 1 expand {{data}} first so its contents are already serialised
.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)) // and won't be touched by later passes.
let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data));
// Step 2 expand individual data fields. Only replace placeholders whose
// key actually exists in ctx.data; leave everything else as-is.
for (const [key, value] of Object.entries(ctx.data)) {
if (value === null || value === undefined) continue;
const placeholder = new RegExp(
`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\}\\}`,
"g"
);
let serialised: string;
if (typeof value === "string") {
serialised = escapeJsonString(value);
} else if (typeof value === "number" || typeof value === "boolean") {
serialised = String(value);
} else {
serialised = escapeJsonString(JSON.stringify(value));
}
rendered = rendered.replace(placeholder, serialised);
}
// Step 3 expand the fixed top-level keys.
rendered = rendered
.replace(/\{\{event\}\}/g, escapeJsonString(ctx.event)) .replace(/\{\{event\}\}/g, escapeJsonString(ctx.event))
.replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp)) .replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
.replace(/\{\{status\}\}/g, escapeJsonString(ctx.status)); .replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));

View File

@@ -16,6 +16,7 @@ import { customers, db, subscriptions } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
export async function handleCustomerCreated( export async function handleCustomerCreated(
customer: Stripe.Customer customer: Stripe.Customer
@@ -62,6 +63,13 @@ export async function handleCustomerCreated(
expiresAt: trialExpiresAt, expiresAt: trialExpiresAt,
trial: true trial: true
}); });
// update to the business limits for the trial
await handleSubscriptionLifesycle(
customer.metadata.orgId,
"active",
"tier3"
);
}); });
logger.info(`Customer with ID ${customer.id} created successfully.`); logger.info(`Customer with ID ${customer.id} created successfully.`);

View File

@@ -44,7 +44,7 @@ function getLimitSetForSubscriptionType(
export async function handleSubscriptionLifesycle( export async function handleSubscriptionLifesycle(
orgId: string, orgId: string,
status: string, status: string,
subType: SubscriptionType | null subType: SubscriptionType | null = null
) { ) {
switch (status) { switch (status) {
case "active": case "active":

View File

@@ -90,14 +90,13 @@ export async function createCertificate(
domainToWrite = `*.${domainToWrite}`; domainToWrite = `*.${domainToWrite}`;
} }
} else if (domainRecord.type == "ns") { } else if (domainRecord.type == "ns") {
// first if we have a * in the domain for this case we dont want to include it because it will mess with the cert generator so remove it if (domain == domainRecord.baseDomain) {
if (domain.startsWith("*.")) { domainToWrite = domainRecord.baseDomain;
domain = domain.slice(2); } else {
} const parts = domain.split(".");
if (parts.length > 2) {
const parts = domain.split("."); domainToWrite = parts.slice(1).join(".");
if (parts.length > 2) { }
domainToWrite = parts.slice(1).join(".");
} }
} }

View File

@@ -24,13 +24,18 @@ import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails"; import { sendEmail } from "@server/emails";
import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring"; import NotifyTrialExpiring from "@server/emails/templates/NotifyTrialExpiring";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { handleSubscriptionLifesycle } from "../billing/subscriptionLifecycle";
const sendTrialNotificationParamsSchema = z.object({ const sendTrialNotificationParamsSchema = z.object({
orgId: z.string() orgId: z.string()
}); });
const sendTrialNotificationBodySchema = z.object({ const sendTrialNotificationBodySchema = z.object({
notificationType: z.enum(["trial_ending_5d", "trial_ending_24h", "trial_ended"]), notificationType: z.enum([
"trial_ending_5d",
"trial_ending_24h",
"trial_ended"
]),
orgName: z.string(), orgName: z.string(),
trialEndsAt: z.number(), trialEndsAt: z.number(),
billingLink: z.string().optional() billingLink: z.string().optional()
@@ -69,9 +74,7 @@ async function getOrgAdmins(orgId: string) {
) )
); );
const byUserId = new Map( const byUserId = new Map(admins.map((a) => [a.userId, a]));
admins.map((a) => [a.userId, a])
);
const orgAdmins = Array.from(byUserId.values()).filter( const orgAdmins = Array.from(byUserId.values()).filter(
(admin) => admin.email && admin.email.length > 0 (admin) => admin.email && admin.email.length > 0
); );
@@ -108,8 +111,12 @@ export async function sendTrialNotification(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { notificationType, orgName, trialEndsAt, billingLink: bodyBillingLink } = const {
parsedBody.data; notificationType,
orgName,
trialEndsAt,
billingLink: bodyBillingLink
} = parsedBody.data;
// Verify organization exists // Verify organization exists
const org = await db const org = await db
@@ -146,13 +153,17 @@ export async function sendTrialNotification(
bodyBillingLink ?? bodyBillingLink ??
`${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`; `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`;
const trialEndsAtFormatted = new Date(trialEndsAt * 1000).toLocaleDateString( const trialEndsAtFormatted = new Date(
"en-US", trialEndsAt * 1000
{ year: "numeric", month: "long", day: "numeric" } ).toLocaleDateString("en-US", {
); year: "numeric",
month: "long",
day: "numeric"
});
let daysRemaining: number | null; let daysRemaining: number | null;
let subject: string; let subject: string;
let resetLimits = false;
if (notificationType === "trial_ending_5d") { if (notificationType === "trial_ending_5d") {
daysRemaining = 5; daysRemaining = 5;
@@ -163,6 +174,7 @@ export async function sendTrialNotification(
} else { } else {
daysRemaining = null; daysRemaining = null;
subject = "Your trial has ended"; subject = "Your trial has ended";
resetLimits = true;
} }
let emailsSent = 0; let emailsSent = 0;
@@ -201,6 +213,14 @@ export async function sendTrialNotification(
} }
} }
if (resetLimits) {
// this will only fire if they have not upgraded yet because when upgrading we delete the trial
await handleSubscriptionLifesycle(orgId, "cancled");
logger.debug(
`Trial ended for org ${orgId}, limits reset to free tier`
);
}
return response<SendTrialNotificationResponse>(res, { return response<SendTrialNotificationResponse>(res, {
data: { data: {
success: true, success: true,

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { clients, db } from "@server/db"; import { clients, db, primaryDb, Client } from "@server/db";
import { userOrgRoles, userOrgs, roles } from "@server/db"; import { userOrgRoles, userOrgs, roles } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -122,8 +122,12 @@ export async function addUserRole(
); );
} }
let newUserRole: { userId: string; orgId: string; roleId: number } | null = let newUserRole: {
null; userId: string;
orgId: string;
roleId: number;
} | null = null;
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const inserted = await trx const inserted = await trx
.insert(userOrgRoles) .insert(userOrgRoles)
@@ -149,11 +153,19 @@ export async function addUserRole(
) )
); );
for (const orgClient of orgClients) { orgClientsToRebuild = orgClients;
await rebuildClientAssociationsFromClient(orgClient, trx);
}
}); });
for (const orgClient of orgClientsToRebuild) {
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
);
}
);
}
return response(res, { return response(res, {
data: newUserRole ?? { userId, orgId: role.orgId, roleId }, data: newUserRole ?? { userId, orgId: role.orgId, roleId },
success: true, success: true,

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { db } from "@server/db"; import { db, primaryDb, Client } from "@server/db";
import { userOrgRoles, userOrgs, roles, clients } from "@server/db"; import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -129,6 +129,7 @@ export async function removeUserRole(
} }
} }
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx
.delete(userOrgRoles) .delete(userOrgRoles)
@@ -150,11 +151,19 @@ export async function removeUserRole(
) )
); );
for (const orgClient of orgClients) { orgClientsToRebuild = orgClients;
await rebuildClientAssociationsFromClient(orgClient, trx);
}
}); });
for (const orgClient of orgClientsToRebuild) {
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}`
);
}
);
}
return response(res, { return response(res, {
data: { userId, orgId: role.orgId, roleId }, data: { userId, orgId: role.orgId, roleId },
success: true, success: true,

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { clients, db } from "@server/db"; import { clients, db, primaryDb, Client } from "@server/db";
import { userOrgRoles, userOrgs, roles } from "@server/db"; import { userOrgRoles, userOrgs, roles } from "@server/db";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -115,6 +115,7 @@ export async function setUserOrgRoles(
); );
} }
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx
.delete(userOrgRoles) .delete(userOrgRoles)
@@ -142,11 +143,19 @@ export async function setUserOrgRoles(
and(eq(clients.userId, userId), eq(clients.orgId, orgId)) and(eq(clients.userId, userId), eq(clients.orgId, orgId))
); );
for (const orgClient of orgClients) { orgClientsToRebuild = orgClients;
await rebuildClientAssociationsFromClient(orgClient, trx);
}
}); });
for (const orgClient of orgClientsToRebuild) {
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}`
);
}
);
}
return response(res, { return response(res, {
data: { userId, orgId, roleIds: uniqueRoleIds }, data: { userId, orgId, roleIds: uniqueRoleIds },
success: true, success: true,

View File

@@ -22,7 +22,7 @@ import {
Olm, Olm,
olms, olms,
RemoteExitNode, RemoteExitNode,
remoteExitNodes, remoteExitNodes
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@server/db"; import { db } from "@server/db";
@@ -194,8 +194,6 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
// Config version tracking map (local to this node, resets on server restart) // Config version tracking map (local to this node, resets on server restart)
const clientConfigVersions: Map<string, number> = new Map(); const clientConfigVersions: Map<string, number> = new Map();
// Recovery tracking // Recovery tracking
let isRedisRecoveryInProgress = false; let isRedisRecoveryInProgress = false;
@@ -406,6 +404,9 @@ const removeClient = async (
const updatedClients = existingClients.filter((client) => client !== ws); const updatedClients = existingClients.filter((client) => client !== ws);
if (updatedClients.length === 0) { if (updatedClients.length === 0) {
connectedClients.delete(mapKey); connectedClients.delete(mapKey);
// Remove clientId from clientConfigVersions on disconnect — prevents
// unbounded memory growth from stale entries.
clientConfigVersions.delete(clientId);
if (redisManager.isRedisEnabled()) { if (redisManager.isRedisEnabled()) {
try { try {
@@ -1097,6 +1098,11 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
} }
}); });
// Eagerly remove client — close event may not fire if socket is already
// CLOSING, leaving zombie entries.
connectedClients.delete(mapKey);
clientConfigVersions.delete(clientId);
return true; return true;
}; };

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, orgs, userOrgs, users } from "@server/db"; import { db, orgs, userOrgs, users, primaryDb } from "@server/db";
import { eq, and, inArray, not } from "drizzle-orm"; import { eq, and, inArray, not } 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";
@@ -218,13 +218,18 @@ export async function deleteMyAccount(
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx.delete(users).where(eq(users.userId, userId)); await trx.delete(users).where(eq(users.userId, userId));
await calculateUserClientsForOrgs(userId, trx);
// loop through the other orgs and decrement the count // loop through the other orgs and decrement the count
for (const userOrg of otherOrgsTheUserWasIn) { for (const userOrg of otherOrgsTheUserWasIn) {
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx); await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
} }
}); });
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
logger.error(
`Failed to calculate user clients after deleting account for user ${userId}: ${e}`
);
});
try { try {
await invalidateSession(session.sessionId); await invalidateSession(session.sessionId);
} catch (error) { } catch (error) {

View File

@@ -1003,7 +1003,11 @@ async function checkRules(
isIpInCidr(clientIp, rule.value) isIpInCidr(clientIp, rule.value)
) { ) {
return rule.action as any; return rule.action as any;
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) { } else if (
clientIp &&
rule.match == "IP" &&
clientIp == rule.value
) {
return rule.action as any; return rule.action as any;
} else if ( } else if (
path && path &&
@@ -1013,16 +1017,35 @@ async function checkRules(
return rule.action as any; return rule.action as any;
} else if ( } else if (
clientIp && clientIp &&
rule.match == "COUNTRY" && rule.match == "COUNTRY"
(await isIpInGeoIP(ipCC, rule.value))
) { ) {
return rule.action as any; // COUNTRY=ALL should not affect local/private/CGNAT addresses.
if (
rule.value.toUpperCase() === "ALL" &&
isLocalOrCarrierGradeNatIp(clientIp)
) {
continue;
}
if (await isIpInGeoIP(ipCC, rule.value)) {
return rule.action as any;
}
} else if ( } else if (
clientIp && clientIp &&
rule.match == "ASN" && rule.match == "ASN"
(await isIpInAsn(ipAsn, rule.value))
) { ) {
return rule.action as any; // ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
if (
(rule.value.toUpperCase() === "ALL" ||
rule.value.toUpperCase() === "AS0") &&
isLocalOrCarrierGradeNatIp(clientIp)
) {
continue;
}
if (await isIpInAsn(ipAsn, rule.value)) {
return rule.action as any;
}
} else if ( } else if (
clientIp && clientIp &&
rule.match == "REGION" && rule.match == "REGION" &&
@@ -1184,6 +1207,26 @@ async function isIpInGeoIP(
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase(); return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
} }
function isLocalOrCarrierGradeNatIp(ip: string): boolean {
const localAndCgnatCidrs = [
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10"
];
try {
return localAndCgnatCidrs.some((cidr) => isIpInCidr(ip, cidr));
} catch {
return false;
}
}
async function isIpInAsn( async function isIpInAsn(
ipAsn: number | undefined, ipAsn: number | undefined,
checkAsn: string checkAsn: string

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, primaryDb } from "@server/db";
import { import {
roles, roles,
Client, Client,
@@ -92,7 +92,10 @@ export async function createClient(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -198,7 +201,10 @@ export async function createClient(
if (!randomExitNode) { if (!randomExitNode) {
return next( return next(
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`) createHttpError(
HttpCode.NOT_FOUND,
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
)
); );
} }
@@ -256,10 +262,18 @@ export async function createClient(
clientId: newClient.clientId, clientId: newClient.clientId,
dateCreated: moment().toISOString() dateCreated: moment().toISOString()
}); });
await rebuildClientAssociationsFromClient(newClient, trx);
}); });
if (newClient) {
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations after creating client: ${e}`
);
}
);
}
return response<CreateClientResponse>(res, { return response<CreateClientResponse>(res, {
data: newClient, data: newClient,
success: true, success: true,

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, primaryDb } from "@server/db";
import { import {
roles, roles,
Client, Client,
@@ -237,10 +237,18 @@ export async function createUserClient(
userId, userId,
clientId: newClient.clientId clientId: newClient.clientId
}); });
await rebuildClientAssociationsFromClient(newClient, trx);
}); });
if (newClient) {
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations after creating user client: ${e}`
);
}
);
}
return response<CreateClientAndOlmResponse>(res, { return response<CreateClientAndOlmResponse>(res, {
data: newClient, data: newClient,
success: true, success: true,

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, olms } from "@server/db"; import { db, olms, primaryDb, Client, Olm } from "@server/db";
import { clients, clientSitesAssociationsCache } from "@server/db"; import { clients, clientSitesAssociationsCache } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -71,14 +71,17 @@ export async function deleteClient(
); );
} }
let deletedClient: Client | undefined;
let olm: Olm | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Then delete the client itself // Then delete the client itself
const [deletedClient] = await trx [deletedClient] = await trx
.delete(clients) .delete(clients)
.where(eq(clients.clientId, clientId)) .where(eq(clients.clientId, clientId))
.returning(); .returning();
const [olm] = await trx [olm] = await trx
.select() .select()
.from(olms) .from(olms)
.where(eq(olms.clientId, clientId)) .where(eq(olms.clientId, clientId))
@@ -88,14 +91,29 @@ export async function deleteClient(
if (!client.userId && client.olmId) { if (!client.userId && client.olmId) {
await trx.delete(olms).where(eq(olms.olmId, client.olmId)); await trx.delete(olms).where(eq(olms.olmId, client.olmId));
} }
await rebuildClientAssociationsFromClient(deletedClient, trx);
if (olm) {
await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion
}
}); });
if (deletedClient) {
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
);
}
);
if (olm) {
sendTerminateClient(
deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
olm.olmId
).catch((e) => {
logger.error(
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting client ${clientId}: ${e}`
);
});
}
}
return response(res, { return response(res, {
data: null, data: null,
success: true, success: true,

View File

@@ -38,10 +38,7 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg";
assignUserToOrg,
removeUserFromOrg
} from "@server/lib/userOrg";
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
const ensureTrailingSlash = (url: string): string => { const ensureTrailingSlash = (url: string): string => {
@@ -344,13 +341,6 @@ export async function validateOidcCallback(
if (!subscribed) { if (!subscribed) {
// filter out the org // filter out the org
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId); allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
// return next(
// createHttpError(
// HttpCode.FORBIDDEN,
// "This organization's current plan does not support this feature."
// )
// );
} }
} }
} else { } else {
@@ -396,16 +386,14 @@ export async function validateOidcCallback(
idpOrgRes?.roleMapping || defaultRoleMapping; idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) { if (roleMapping) {
logger.debug("Role Mapping", { roleMapping }); logger.debug("Role Mapping", { roleMapping });
const roleMappingJmes = unwrapRoleMapping( const roleMappingJmes =
roleMapping unwrapRoleMapping(roleMapping).evaluationExpression;
).evaluationExpression;
const roleMappingResult = jmespath.search( const roleMappingResult = jmespath.search(
claims, claims,
roleMappingJmes roleMappingJmes
); );
const roleNames = normalizeRoleMappingResult( const roleNames =
roleMappingResult normalizeRoleMappingResult(roleMappingResult);
);
const supportsMultiRole = await isLicensedOrSubscribed( const supportsMultiRole = await isLicensedOrSubscribed(
org.orgId, org.orgId,
@@ -495,7 +483,14 @@ export async function validateOidcCallback(
} }
} }
await calculateUserClientsForOrgs(existingUser.userId); calculateUserClientsForOrgs(existingUser.userId).catch(
(err) => {
logger.error(
"Error calculating user clients after removing all orgs for user with no valid IdP mappings",
{ error: err }
);
}
);
return next( return next(
createHttpError( createHttpError(
@@ -515,12 +510,11 @@ export async function validateOidcCallback(
} }
} }
const orgUserCounts: { orgId: string; userCount: number }[] = []; const orgUserCounts: { orgId: string; userCount: number }[] = [];
let userId = existingUser?.userId;
// sync the user with the orgs and roles // sync the user with the orgs and roles
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists // create user if not exists
if (!existingUser) { if (!existingUser) {
userId = generateId(15); userId = generateId(15);
@@ -628,7 +622,7 @@ export async function validateOidcCallback(
{ {
orgId: org.orgId, orgId: org.orgId,
userId: userId!, userId: userId!,
autoProvisioned: true, autoProvisioned: true
}, },
org.roleIds, org.roleIds,
trx trx
@@ -650,8 +644,15 @@ export async function validateOidcCallback(
userCount: userCount.length userCount: userCount.length
}); });
} }
});
db.transaction(async (trx) => {
await calculateUserClientsForOrgs(userId!, trx); await calculateUserClientsForOrgs(userId!, trx);
}).catch((err) => {
logger.error(
"Error calculating user clients after syncing orgs and roles for OIDC user",
{ error: err }
);
}); });
for (const orgCount of orgUserCounts) { for (const orgCount of orgUserCounts) {
@@ -758,9 +759,7 @@ function hydrateOrgMapping(
return orgMapping.split("{{orgId}}").join(orgId); return orgMapping.split("{{orgId}}").join(orgId);
} }
function normalizeRoleMappingResult( function normalizeRoleMappingResult(result: unknown): string[] {
result: unknown
): string[] {
if (typeof result === "string") { if (typeof result === "string") {
const role = result.trim(); const role = result.trim();
return role ? [role] : []; return role ? [role] : [];
@@ -770,7 +769,9 @@ function normalizeRoleMappingResult(
return [ return [
...new Set( ...new Set(
result result
.filter((value): value is string => typeof value === "string") .filter(
(value): value is string => typeof value === "string"
)
.map((value) => value.trim()) .map((value) => value.trim())
.filter(Boolean) .filter(Boolean)
) )

View File

@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { db, olms } from "@server/db"; import { db, olms, primaryDb } from "@server/db";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { z } from "zod"; import { z } from "zod";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -81,16 +81,19 @@ export async function createUserOlm(
const secretHash = await hashPassword(secret); const secretHash = await hashPassword(secret);
await db.transaction(async (trx) => { await db.insert(olms).values({
await trx.insert(olms).values({ olmId: olmId,
olmId: olmId, userId,
userId, name,
name, secretHash,
secretHash, dateCreated: moment().toISOString()
dateCreated: moment().toISOString() });
});
await calculateUserClientsForOrgs(userId, trx); calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
console.error(
"Error calculating user clients after creating olm:",
e
);
}); });
return response<CreateOlmResponse>(res, { return response<CreateOlmResponse>(res, {

View File

@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { Client, db } from "@server/db"; import { Client, db, Olm, primaryDb } from "@server/db";
import { olms, clients, clientSitesAssociationsCache } from "@server/db"; import { olms, clients, clientSitesAssociationsCache } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -49,6 +49,7 @@ export async function deleteUserOlm(
const { olmId } = parsedParams.data; const { olmId } = parsedParams.data;
let deletedClient: Client | undefined;
// Delete associated clients and the OLM in a transaction // Delete associated clients and the OLM in a transaction
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Find all clients associated with this OLM // Find all clients associated with this OLM
@@ -57,7 +58,6 @@ export async function deleteUserOlm(
.from(clients) .from(clients)
.where(eq(clients.olmId, olmId)); .where(eq(clients.olmId, olmId));
let deletedClient: Client | null = null;
// Delete all associated clients // Delete all associated clients
if (associatedClients.length > 0) { if (associatedClients.length > 0) {
[deletedClient] = await trx [deletedClient] = await trx
@@ -67,23 +67,28 @@ export async function deleteUserOlm(
} }
// Finally, delete the OLM itself // Finally, delete the OLM itself
const [olm] = await trx await trx.delete(olms).where(eq(olms.olmId, olmId)).returning();
.delete(olms)
.where(eq(olms.olmId, olmId))
.returning();
if (deletedClient) {
await rebuildClientAssociationsFromClient(deletedClient, trx);
if (olm) {
await sendTerminateClient(
deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
olm.olmId
); // the olmId needs to be provided because it cant look it up after deletion
}
}
}); });
if (deletedClient) {
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
);
}
);
sendTerminateClient(
deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
olmId
).catch((e) => {
logger.error(
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting OLM ${olmId}: ${e}`
);
});
}
return response(res, { return response(res, {
data: null, data: null,
success: true, success: true,

View File

@@ -22,14 +22,14 @@ import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
export const handleOlmRegisterMessage: MessageHandler = async (context) => { export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!"); logger.info("[handleOlmRegisterMessage] Handling register olm message");
const { message, client: c, sendToClient } = context; const { message, client: c, sendToClient } = context;
const olm = c as Olm; const olm = c as Olm;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
if (!olm) { if (!olm) {
logger.warn("Olm not found"); logger.warn("[handleOlmRegisterMessage] Olm not found");
return; return;
} }
@@ -46,16 +46,19 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} = message.data; } = message.data;
if (!olm.clientId) { if (!olm.clientId) {
logger.warn("Olm client ID not found"); logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
return; return;
} }
logger.debug("Handling fingerprint insertion for olm register...", { logger.debug(
olmId: olm.olmId, "[handleOlmRegisterMessage] Handling fingerprint insertion for olm register...",
fingerprint, {
postures olmId: olm.olmId,
}); fingerprint,
postures
}
);
const isUserDevice = olm.userId !== null && olm.userId !== undefined; const isUserDevice = olm.userId !== null && olm.userId !== undefined;
@@ -85,14 +88,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.limit(1); .limit(1);
if (!client) { if (!client) {
logger.warn("Client ID not found"); logger.warn("[handleOlmRegisterMessage] Client not found", {
clientId: olm.clientId
});
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
return; return;
} }
if (client.blocked) { if (client.blocked) {
logger.debug( logger.debug(
`Client ${client.clientId} is blocked. Ignoring register.` `[handleOlmRegisterMessage] Client ${client.clientId} is blocked. Ignoring register.`,
{ orgId: client.orgId }
); );
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
return; return;
@@ -100,7 +106,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.approvalState == "pending") { if (client.approvalState == "pending") {
logger.debug( logger.debug(
`Client ${client.clientId} approval is pending. Ignoring register.` `[handleOlmRegisterMessage] Client ${client.clientId} approval is pending. Ignoring register.`,
{ orgId: client.orgId }
); );
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId); sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
return; return;
@@ -128,14 +135,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.limit(1); .limit(1);
if (!org) { if (!org) {
logger.warn("Org not found"); logger.warn("[handleOlmRegisterMessage] Org not found", {
orgId: client.orgId
});
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
return; return;
} }
if (orgId) { if (orgId) {
if (!olm.userId) { if (!olm.userId) {
logger.warn("Olm has no user ID"); logger.warn("[handleOlmRegisterMessage] Olm has no user ID", {
orgId: client.orgId
});
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId); sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
return; return;
} }
@@ -143,12 +154,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
const { session: userSession, user } = const { session: userSession, user } =
await validateSessionToken(userToken); await validateSessionToken(userToken);
if (!userSession || !user) { if (!userSession || !user) {
logger.warn("Invalid user session for olm register"); logger.warn(
"[handleOlmRegisterMessage] Invalid user session for olm register",
{ orgId: client.orgId }
);
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId); sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
return; return;
} }
if (user.userId !== olm.userId) { if (user.userId !== olm.userId) {
logger.warn("User ID mismatch for olm register"); logger.warn(
"[handleOlmRegisterMessage] User ID mismatch for olm register",
{ orgId: client.orgId }
);
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId); sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
return; return;
} }
@@ -163,11 +180,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sessionId // this is the user token passed in the message sessionId // this is the user token passed in the message
}); });
logger.debug("Policy check result:", policyCheck); logger.debug("[handleOlmRegisterMessage] Policy check result", {
orgId: client.orgId,
policyCheck
});
if (policyCheck?.error) { if (policyCheck?.error) {
logger.error( logger.error(
`Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}` `[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
{ orgId: client.orgId }
); );
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return; return;
@@ -175,7 +196,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (policyCheck.policies?.passwordAge?.compliant === false) { if (policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}` `[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
{ orgId: client.orgId }
); );
sendOlmError( sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED, OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
@@ -186,7 +208,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
policyCheck.policies?.maxSessionLength?.compliant === false policyCheck.policies?.maxSessionLength?.compliant === false
) { ) {
logger.warn( logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}` `[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant session length for org ${orgId}`,
{ orgId: client.orgId }
); );
sendOlmError( sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED, OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
@@ -195,7 +218,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} else if (policyCheck.policies?.requiredTwoFactor === false) { } else if (policyCheck.policies?.requiredTwoFactor === false) {
logger.warn( logger.warn(
`Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}` `[handleOlmRegisterMessage] Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`,
{ orgId: client.orgId }
); );
sendOlmError( sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED, OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
@@ -204,7 +228,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return; return;
} else if (!policyCheck.allowed) { } else if (!policyCheck.allowed) {
logger.warn( logger.warn(
`Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` `[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
{ orgId: client.orgId }
); );
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return; return;
@@ -226,29 +251,39 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0; sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
// Prepare an array to store site configurations // Prepare an array to store site configurations
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`); logger.debug(
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
{ orgId: client.orgId }
);
let jitMode = false; let jitMode = false;
if (sitesCount > 250 && build == "saas") { if (sitesCount > 250 && build == "saas") {
// THIS IS THE MAX ON THE BUSINESS TIER // THIS IS THE MAX ON THE BUSINESS TIER
// we have too many sites // we have too many sites
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites // If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount); logger.info(
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
{ orgId: client.orgId }
);
jitMode = true; jitMode = true;
} }
logger.debug( logger.debug(
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` `[handleOlmRegisterMessage] Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`,
{ orgId: client.orgId }
); );
if (!publicKey) { if (!publicKey) {
logger.warn("Public key not provided"); logger.warn("[handleOlmRegisterMessage] Public key not provided", {
orgId: client.orgId
});
return; return;
} }
if (client.pubKey !== publicKey || client.archived) { if (client.pubKey !== publicKey || client.archived) {
logger.info( logger.info(
"Public key mismatch. Updating public key and clearing session info..." "[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
{ orgId: client.orgId }
); );
// Update the client's public key // Update the client's public key
await db await db
@@ -274,12 +309,13 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
// TODO: I still think there is a better way to do this rather than locking it out here but ??? // TODO: I still think there is a better way to do this rather than locking it out here but ???
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) { if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
logger.warn( logger.warn(
`Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` `[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`,
{ orgId: client.orgId }
); );
return; return;
} }
// NOTE: its important that the client here is the old client and the public key is the new key // NOTE: its important that the client here is the old client and the public key is the new key
const siteConfigurations = await buildSiteConfigurationForOlmClient( const siteConfigurations = await buildSiteConfigurationForOlmClient(
client, client,
publicKey, publicKey,

View File

@@ -151,6 +151,8 @@ export async function getUserResources(
destination: string; destination: string;
mode: string; mode: string;
scheme: string | null; scheme: string | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
@@ -164,6 +166,8 @@ export async function getUserResources(
destination: siteResources.destination, destination: siteResources.destination,
mode: siteResources.mode, mode: siteResources.mode,
scheme: siteResources.scheme, scheme: siteResources.scheme,
ssl: siteResources.ssl,
fullDomain: siteResources.fullDomain,
enabled: siteResources.enabled, enabled: siteResources.enabled,
alias: siteResources.alias, alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress aliasAddress: siteResources.aliasAddress
@@ -251,6 +255,8 @@ export async function getUserResources(
destination: siteResource.destination, destination: siteResource.destination,
mode: siteResource.mode, mode: siteResource.mode,
protocol: siteResource.scheme, protocol: siteResource.scheme,
ssl: siteResource.ssl,
fullDomain: siteResource.fullDomain,
enabled: siteResource.enabled, enabled: siteResource.enabled,
alias: siteResource.alias, alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress, aliasAddress: siteResource.aliasAddress,
@@ -296,6 +302,8 @@ export type GetUserResourcesResponse = {
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null; protocol: string | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;

View File

@@ -5,7 +5,8 @@ import {
clients, clients,
clientSiteResources, clientSiteResources,
siteResources, siteResources,
apiKeyOrg apiKeyOrg,
primaryDb
} from "@server/db"; } 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";
@@ -220,8 +221,12 @@ export async function batchAddClientToSiteResources(
siteResourceId: siteResource.siteResourceId siteResourceId: siteResource.siteResourceId
}); });
} }
});
await rebuildClientAssociationsFromClient(client, trx); rebuildClientAssociationsFromClient(client, primaryDb).catch((e) => {
logger.error(
`Failed to rebuild client associations after batch adding site resources for client ${clientId}: ${e}`
);
}); });
return response(res, { return response(res, {

View File

@@ -10,7 +10,8 @@ import {
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
userSiteResources userSiteResources,
primaryDb
} from "@server/db"; } from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names"; import { getUniqueSiteResourceName } from "@server/db/names";
import { import {
@@ -74,16 +75,14 @@ const createSiteResourceSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host") { if (data.mode === "host") {
if (data.mode == "host") { // Check if it's a valid IP address using zod (v4 or v6)
// Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z
const isValidIP = z // .union([z.ipv4(), z.ipv6()])
// .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .safeParse(data.destination).success;
.safeParse(data.destination).success;
if (isValidIP) { if (isValidIP) {
return true; return true;
}
} }
// Check if it's a valid domain (hostname pattern, TLD not required) // Check if it's a valid domain (hostname pattern, TLD not required)
@@ -96,17 +95,12 @@ const createSiteResourceSchema = z
data.alias.trim() !== ""; data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
} } else if (data.mode === "http") {
return true; // we have to have a domainId defined
}, if (!data.domainId) {
{ return false;
message: }
"Destination must be a valid IPV4 address or valid domain AND alias is required" } else if (data.mode === "cidr") {
}
)
.refine(
(data) => {
if (data.mode === "cidr") {
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()]) .union([z.cidrv4(), z.cidrv6()])
@@ -116,7 +110,8 @@ const createSiteResourceSchema = z
return true; return true;
}, },
{ {
message: "Destination must be a valid CIDR notation for cidr mode" message:
"Destination must be a valid IPV4 address or valid domain AND alias is required"
} }
) )
.refine( .refine(
@@ -525,12 +520,10 @@ export async function createSiteResource(
// own transaction so it always executes on the primary — avoiding any // own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return // replica-lag issues while still allowing the HTTP response to return
// early. // early.
db.transaction(async (trx) => { rebuildClientAssociationsFromSiteResource(
await rebuildClientAssociationsFromSiteResource( newSiteResource!,
newSiteResource!, primaryDb
trx ).catch((err) => {
);
}).catch((err) => {
logger.error( logger.error(
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
err err

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, newts, sites } from "@server/db"; import { db, newts, primaryDb, sites } from "@server/db";
import { siteResources } from "@server/db"; import { siteResources } 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";
@@ -73,12 +73,10 @@ export async function deleteSiteResource(
// own transaction so it always executes on the primary — avoiding any // own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return // replica-lag issues while still allowing the HTTP response to return
// early. // early.
db.transaction(async (trx) => { rebuildClientAssociationsFromSiteResource(
await rebuildClientAssociationsFromSiteResource( removedSiteResource,
removedSiteResource, primaryDb
trx ).catch((err) => {
);
}).catch((err) => {
logger.error( logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err err

View File

@@ -104,6 +104,17 @@ const updateSiteResourceSchema = z
data.alias.trim() !== ""; data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
} else if (data.mode === "cidr" && data.destination) {
// Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()])
.safeParse(data.destination).success;
return isValidCIDR;
} else if (data.mode === "http") {
// we have to have a domainId defined
if (!data.domainId) {
return false;
}
} }
return true; return true;
}, },
@@ -112,21 +123,6 @@ const updateSiteResourceSchema = z
"Destination must be a valid IP address or valid domain AND alias is required" "Destination must be a valid IP address or valid domain AND alias is required"
} }
) )
.refine(
(data) => {
if (data.mode === "cidr" && data.destination) {
// Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()])
.safeParse(data.destination).success;
return isValidCIDR;
}
return true;
},
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
)
.refine( .refine(
(data) => { (data) => {
if (data.mode !== "http") return true; if (data.mode !== "http") return true;

View File

@@ -56,6 +56,8 @@ function queryTargets(resourceId: number) {
hcStatus: targetHealthCheck.hcStatus, hcStatus: targetHealthCheck.hcStatus,
hcHealth: targetHealthCheck.hcHealth, hcHealth: targetHealthCheck.hcHealth,
hcTlsServerName: targetHealthCheck.hcTlsServerName, hcTlsServerName: targetHealthCheck.hcTlsServerName,
hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold,
path: targets.path, path: targets.path,
pathMatchType: targets.pathMatchType, pathMatchType: targets.pathMatchType,
rewritePath: targets.rewritePath, rewritePath: targets.rewritePath,

View File

@@ -1,7 +1,13 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, orgs } from "@server/db"; import { db, orgs, primaryDb } from "@server/db";
import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; import {
roles,
userInviteRoles,
userInvites,
userOrgs,
users
} from "@server/db";
import { eq, and, inArray } from "drizzle-orm"; import { eq, and, inArray } 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";
@@ -146,9 +152,7 @@ export async function acceptInvite(
.from(userInviteRoles) .from(userInviteRoles)
.where(eq(userInviteRoles.inviteId, inviteId)); .where(eq(userInviteRoles.inviteId, inviteId));
const inviteRoleIds = [ const inviteRoleIds = [...new Set(inviteRoleRows.map((r) => r.roleId))];
...new Set(inviteRoleRows.map((r) => r.roleId))
];
if (inviteRoleIds.length === 0) { if (inviteRoleIds.length === 0) {
return next( return next(
createHttpError( createHttpError(
@@ -193,13 +197,19 @@ export async function acceptInvite(
.delete(userInvites) .delete(userInvites)
.where(eq(userInvites.inviteId, inviteId)); .where(eq(userInvites.inviteId, inviteId));
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
logger.debug( logger.debug(
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}` `User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}`
); );
}); });
calculateUserClientsForOrgs(existingUser[0].userId, primaryDb).catch(
(e) => {
logger.error(
`Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}`
);
}
);
return response<AcceptInviteResponse>(res, { return response<AcceptInviteResponse>(res, {
data: { accepted: true, orgId: existingInvite.orgId }, data: { accepted: true, orgId: existingInvite.orgId },
success: true, success: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { clients, db } from "@server/db"; import { clients, db, primaryDb, Client } from "@server/db";
import { userOrgRoles, userOrgs, roles } from "@server/db"; import { userOrgRoles, userOrgs, roles } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -112,6 +112,8 @@ export async function addUserRoleLegacy(
); );
} }
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx
.delete(userOrgRoles) .delete(userOrgRoles)
@@ -138,11 +140,19 @@ export async function addUserRoleLegacy(
) )
); );
for (const orgClient of orgClients) { orgClientsToRebuild = orgClients;
await rebuildClientAssociationsFromClient(orgClient, trx);
}
}); });
for (const orgClient of orgClientsToRebuild) {
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
(e) => {
logger.error(
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
);
}
);
}
return response(res, { return response(res, {
data: { ...existingUser, roleId }, data: { ...existingUser, roleId },
success: true, success: true,

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, primaryDb } from "@server/db";
import { users } from "@server/db"; import { users } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -53,8 +53,12 @@ export async function adminRemoveUser(
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx.delete(users).where(eq(users.userId, userId)); await trx.delete(users).where(eq(users.userId, userId));
});
await calculateUserClientsForOrgs(userId, trx); calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
logger.error(
`Failed to calculate user clients after removing user ${userId}: ${e}`
);
}); });
return response(res, { return response(res, {

View File

@@ -6,7 +6,7 @@ import createHttpError from "http-errors";
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";
import { db, orgs } from "@server/db"; import { db, orgs, primaryDb } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
@@ -34,8 +34,7 @@ const bodySchema = z
roleId: z.number().int().positive().optional() roleId: z.number().int().positive().optional()
}) })
.refine( .refine(
(d) => (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] } { message: "roleIds or roleId is required", path: ["roleIds"] }
) )
.transform((data) => ({ .transform((data) => ({
@@ -100,8 +99,14 @@ export async function createOrgUser(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { username, email, name, type, idpId, roleIds: uniqueRoleIds } = const {
parsedBody.data; username,
email,
name,
type,
idpId,
roleIds: uniqueRoleIds
} = parsedBody.data;
if (build == "saas") { if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.USERS); const usage = await usageService.getUsage(orgId, FeatureId.USERS);
@@ -232,6 +237,7 @@ export async function createOrgUser(
); );
} }
let userIdForClients: string | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const [existingUser] = await trx const [existingUser] = await trx
.select() .select()
@@ -270,7 +276,7 @@ export async function createOrgUser(
{ {
orgId, orgId,
userId: existingUser.userId, userId: existingUser.userId,
autoProvisioned: false, autoProvisioned: false
}, },
uniqueRoleIds, uniqueRoleIds,
trx trx
@@ -292,20 +298,30 @@ export async function createOrgUser(
}) })
.returning(); .returning();
await assignUserToOrg( await assignUserToOrg(
org, org,
{ {
orgId, orgId,
userId: newUser.userId, userId: newUser.userId,
autoProvisioned: false, autoProvisioned: false
}, },
uniqueRoleIds, uniqueRoleIds,
trx trx
); );
} }
await calculateUserClientsForOrgs(userId, trx); userIdForClients = userId;
}); });
if (userIdForClients) {
calculateUserClientsForOrgs(userIdForClients, primaryDb).catch(
(e) => {
logger.error(
`Failed to calculate user clients after creating org user: ${e}`
);
}
);
}
} else { } else {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required") createHttpError(HttpCode.BAD_REQUEST, "User type is required")

View File

@@ -7,7 +7,8 @@ import {
siteResources, siteResources,
sites, sites,
UserOrg, UserOrg,
userSiteResources userSiteResources,
primaryDb
} from "@server/db"; } from "@server/db";
import { userOrgs, userResources, users, userSites } from "@server/db"; import { userOrgs, userResources, users, userSites } from "@server/db";
import { and, count, eq, exists, inArray } from "drizzle-orm"; import { and, count, eq, exists, inArray } from "drizzle-orm";
@@ -91,25 +92,12 @@ export async function removeUserOrg(
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await removeUserFromOrg(org, userId, trx); await removeUserFromOrg(org, userId, trx);
});
// if (build === "saas") { calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
// const [rootUser] = await trx logger.error(
// .select() `Failed to calculate user clients after removing user ${userId} from org ${orgId}: ${e}`
// .from(users) );
// .where(eq(users.userId, userId));
//
// const [leftInOrgs] = await trx
// .select({ count: count() })
// .from(userOrgs)
// .where(eq(userOrgs.userId, userId));
//
// // if the user is not an internal user and does not belong to any org, delete the entire user
// if (rootUser?.type !== UserType.Internal && !leftInOrgs.count) {
// await trx.delete(users).where(eq(users.userId, userId));
// }
// }
await calculateUserClientsForOrgs(userId, trx);
}); });
return response(res, { return response(res, {

View File

@@ -3,7 +3,15 @@ import zlib from "zlib";
import { Server as HttpServer } from "http"; import { Server as HttpServer } from "http";
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import { Socket } from "net"; import { Socket } from "net";
import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db"; import {
Newt,
newts,
NewtSession,
olms,
Olm,
OlmSession,
sites
} from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@server/db"; import { db } from "@server/db";
import { recordPing } from "@server/routers/newt/pingAccumulator"; import { recordPing } from "@server/routers/newt/pingAccumulator";
@@ -80,6 +88,9 @@ const removeClient = async (
const updatedClients = existingClients.filter((client) => client !== ws); const updatedClients = existingClients.filter((client) => client !== ws);
if (updatedClients.length === 0) { if (updatedClients.length === 0) {
connectedClients.delete(mapKey); connectedClients.delete(mapKey);
// Remove clientId from clientConfigVersions — prevents unbounded growth
// from stale entries.
clientConfigVersions.delete(clientId);
logger.info( logger.info(
`All connections removed for ${clientType.toUpperCase()} ID: ${clientId}` `All connections removed for ${clientType.toUpperCase()} ID: ${clientId}`
@@ -218,9 +229,13 @@ const hasActiveConnections = async (clientId: string): Promise<boolean> => {
}; };
// Get the current config version for a client // Get the current config version for a client
const getClientConfigVersion = async (clientId: string): Promise<number | undefined> => { const getClientConfigVersion = async (
clientId: string
): Promise<number | undefined> => {
const version = clientConfigVersions.get(clientId); const version = clientConfigVersions.get(clientId);
logger.debug(`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`); logger.debug(
`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`
);
return version; return version;
}; };
@@ -507,6 +522,11 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
} }
}); });
// Eagerly remove client — close event may not fire if socket already
// CLOSING, leaving zombie entries.
connectedClients.delete(mapKey);
clientConfigVersions.delete(clientId);
return true; return true;
}; };

View File

@@ -23,6 +23,7 @@ import m14 from "./scriptsPg/1.15.4";
import m15 from "./scriptsPg/1.16.0"; import m15 from "./scriptsPg/1.16.0";
import m16 from "./scriptsPg/1.17.0"; import m16 from "./scriptsPg/1.17.0";
import m17 from "./scriptsPg/1.18.0"; import m17 from "./scriptsPg/1.18.0";
import m18 from "./scriptsPg/1.18.3";
// 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
@@ -45,7 +46,8 @@ const migrations = [
{ version: "1.15.4", run: m14 }, { version: "1.15.4", run: m14 },
{ version: "1.16.0", run: m15 }, { version: "1.16.0", run: m15 },
{ version: "1.17.0", run: m16 }, { version: "1.17.0", run: m16 },
{ version: "1.18.0", run: m17 } { version: "1.18.0", run: m17 },
{ version: "1.18.3", run: m18 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as { ] as {
version: string; version: string;

View File

@@ -41,6 +41,7 @@ import m35 from "./scriptsSqlite/1.15.4";
import m36 from "./scriptsSqlite/1.16.0"; import m36 from "./scriptsSqlite/1.16.0";
import m37 from "./scriptsSqlite/1.17.0"; import m37 from "./scriptsSqlite/1.17.0";
import m38 from "./scriptsSqlite/1.18.0"; import m38 from "./scriptsSqlite/1.18.0";
import m39 from "./scriptsSqlite/1.18.3";
// 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
@@ -79,7 +80,8 @@ const migrations = [
{ version: "1.15.4", run: m35 }, { version: "1.15.4", run: m35 },
{ version: "1.16.0", run: m36 }, { version: "1.16.0", run: m36 },
{ version: "1.17.0", run: m37 }, { version: "1.17.0", run: m37 },
{ version: "1.18.0", run: m38 } { version: "1.18.0", run: m38 },
{ version: "1.18.3", run: m39 }
// 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,169 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.18.3";
export default async function migration() {
console.log(`Running setup script ${version}...`);
// Query existing targetHealthCheck data with joined siteId and orgId before
// the transaction adds the new columns (which start NULL for existing rows).
// We will delete all rows and reinsert them with targetHealthCheckId = targetId
// so the two IDs form a stable 1:1 mapping.
const healthChecksQuery = await db.execute(
sql`SELECT
thc."targetHealthCheckId",
thc."targetId",
t."siteId",
s."orgId",
r."name" AS "resourceName",
t."ip",
t."port"
FROM "targetHealthCheck" thc
JOIN "targets" t ON thc."targetId" = t."targetId"
JOIN "sites" s ON t."siteId" = s."siteId"
JOIN "resources" r ON t."resourceId" = r."resourceId"
WHERE thc."name" IS NULL OR thc."name" = ''`
);
const existingHealthChecks = healthChecksQuery.rows as {
targetHealthCheckId: number;
targetId: number;
siteId: number;
orgId: string;
resourceName: string;
ip: string;
port: number;
}[];
console.log(
`Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate`
);
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
CREATE TABLE IF NOT EXISTS "trialNotifications" (
"notificationId" serial PRIMARY KEY NOT NULL,
"subscriptionId" varchar(255) NOT NULL,
"notificationType" varchar(50) NOT NULL,
"sentAt" bigint NOT NULL
);
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
if (existingHealthChecks.length > 0) {
// fix the name column
try {
for (const hc of existingHealthChecks) {
await db.execute(sql`
UPDATE "targetHealthCheck"
SET "name" = ${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`}
WHERE "targetHealthCheckId" = ${hc.targetHealthCheckId}
`);
}
console.log(
`Updated names for ${existingHealthChecks.length} existing targetHealthCheck row(s)`
);
} catch (e) {
console.error("Error while migrating targetHealthCheck rows:", e);
throw e;
}
}
// Recompute resource health by aggregating across the resource's targets'
// target health checks, then update the resources.health column to match.
try {
const resourceTargetHealthQuery = await db.execute(
sql`SELECT
r."resourceId" AS "resourceId",
r."orgId" AS "orgId",
r."health" AS "currentHealth",
thc."hcHealth" AS "hcHealth"
FROM "resources" r
LEFT JOIN "targets" t ON t."resourceId" = r."resourceId"
LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"`
);
const resourceTargetHealthRows = resourceTargetHealthQuery.rows as {
resourceId: number;
orgId: string;
currentHealth: string | null;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{
hasHealthy: boolean;
hasUnhealthy: boolean;
hasUnknown: boolean;
orgId: string;
currentHealth: string | null;
}
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false,
orgId: row.orgId,
currentHealth: row.currentHealth
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
const now = Math.floor(Date.now() / 1000);
let updatedResourceCount = 0;
for (const [resourceId, entry] of resourceHealthMap.entries()) {
let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown";
if (entry.hasHealthy && entry.hasUnhealthy) {
aggregated = "degraded";
} else if (entry.hasHealthy) {
aggregated = "healthy";
} else if (entry.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
if (entry.currentHealth !== aggregated) {
await db.execute(sql`
UPDATE "resources"
SET "health" = ${aggregated}
WHERE "resourceId" = ${resourceId}
`);
await db.execute(sql`
INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp")
VALUES ('resource', ${resourceId}, ${entry.orgId}, ${aggregated}, ${now})
`);
updatedResourceCount++;
}
}
console.log(
`Recomputed health for ${updatedResourceCount} resource(s) based on target health checks`
);
} catch (e) {
console.error(
"Error while recomputing resource health from target health checks:",
e
);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,172 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.18.3";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.prepare(
`
CREATE TABLE IF NOT EXISTS 'trialNotifications' (
'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'subscriptionId' text NOT NULL,
'notificationType' text NOT NULL,
'sentAt' integer NOT NULL,
FOREIGN KEY ('subscriptionId') REFERENCES 'subscriptions'('subscriptionId') ON UPDATE no action ON DELETE cascade
);
`
).run();
})();
db.pragma("foreign_keys = ON");
console.log("Migrated database");
// Fix names for health checks that don't have one
const healthChecksWithoutName = db
.prepare(
`SELECT
thc."targetHealthCheckId",
r."name" AS "resourceName",
t."ip",
t."port"
FROM 'targetHealthCheck' thc
JOIN 'targets' t ON thc."targetId" = t."targetId"
JOIN 'resources' r ON t."resourceId" = r."resourceId"
WHERE thc."name" IS NULL OR thc."name" = ''`
)
.all() as {
targetHealthCheckId: number;
resourceName: string;
ip: string;
port: number;
}[];
console.log(
`Found ${healthChecksWithoutName.length} targetHealthCheck row(s) with missing names`
);
if (healthChecksWithoutName.length > 0) {
const updateName = db.prepare(
`UPDATE 'targetHealthCheck' SET "name" = ? WHERE "targetHealthCheckId" = ?`
);
const updateAllNames = db.transaction(() => {
for (const hc of healthChecksWithoutName) {
updateName.run(
`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`,
hc.targetHealthCheckId
);
}
});
updateAllNames();
console.log(
`Updated names for ${healthChecksWithoutName.length} targetHealthCheck row(s)`
);
}
// Recompute resource health by aggregating across the resource's
// targets' target health checks, then update resources.health and
// insert a statusHistory entry for any resource whose health changed.
const resourceTargetHealthRows = db
.prepare(
`SELECT
r."resourceId" AS "resourceId",
r."orgId" AS "orgId",
r."health" AS "currentHealth",
thc."hcHealth" AS "hcHealth"
FROM 'resources' r
LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId"
LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"`
)
.all() as {
resourceId: number;
orgId: string;
currentHealth: string | null;
hcHealth: string | null;
}[];
const resourceHealthMap = new Map<
number,
{
hasHealthy: boolean;
hasUnhealthy: boolean;
hasUnknown: boolean;
orgId: string;
currentHealth: string | null;
}
>();
for (const row of resourceTargetHealthRows) {
const entry = resourceHealthMap.get(row.resourceId) ?? {
hasHealthy: false,
hasUnhealthy: false,
hasUnknown: false,
orgId: row.orgId,
currentHealth: row.currentHealth
};
const status = row.hcHealth ?? "unknown";
if (status === "healthy") entry.hasHealthy = true;
else if (status === "unhealthy") entry.hasUnhealthy = true;
else entry.hasUnknown = true;
resourceHealthMap.set(row.resourceId, entry);
}
const updateResourceHealth = db.prepare(
`UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?`
);
const insertResourceHistory = db.prepare(
`INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)`
);
const now = Math.floor(Date.now() / 1000);
let updatedResourceCount = 0;
const recomputeAll = db.transaction(() => {
for (const [resourceId, entry] of resourceHealthMap.entries()) {
let aggregated:
| "healthy"
| "unhealthy"
| "degraded"
| "unknown";
if (entry.hasHealthy && entry.hasUnhealthy) {
aggregated = "degraded";
} else if (entry.hasHealthy) {
aggregated = "healthy";
} else if (entry.hasUnhealthy) {
aggregated = "unhealthy";
} else {
aggregated = "unknown";
}
if (entry.currentHealth !== aggregated) {
updateResourceHealth.run(aggregated, resourceId);
insertResourceHistory.run(
"resource",
resourceId,
entry.orgId,
aggregated,
now
);
updatedResourceCount++;
}
}
});
recomputeAll();
console.log(
`Recomputed health for ${updatedResourceCount} resource(s) based on target health checks`
);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -35,6 +35,7 @@ import {
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react"; import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
import { import {
Tooltip, Tooltip,
@@ -55,6 +56,7 @@ import {
tier3LimitSet tier3LimitSet
} from "@server/lib/billing/limitSet"; } from "@server/lib/billing/limitSet";
import { FeatureId } from "@server/lib/billing/features"; import { FeatureId } from "@server/lib/billing/features";
import TrialBillingBanner from "@app/components/TrialBillingBanner";
// Plan tier definitions matching the mockup // Plan tier definitions matching the mockup
type PlanId = "basic" | "home" | "team" | "business" | "enterprise"; type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
@@ -805,6 +807,20 @@ export default function BillingPage() {
return ( return (
<SettingsContainer> <SettingsContainer>
{/* Trial Banner */}
{isTrial && (
<TrialBillingBanner
onUpgrade={() => {
const currentPlan = planOptions.find(
(p) => p.id === currentPlanId
);
if (currentPlan?.tierType) {
handleStartSubscription(currentPlan.tierType);
}
}}
/>
)}
{/* Subscription Status Alert */} {/* Subscription Status Alert */}
{isProblematicState && statusMessage && ( {isProblematicState && statusMessage && (
<Alert variant="destructive" className="mb-6"> <Alert variant="destructive" className="mb-6">
@@ -859,8 +875,19 @@ export default function BillingPage() {
)} )}
> >
<div className="flex-1"> <div className="flex-1">
<div className="text-2xl"> <div className="flex items-center gap-2 flex-wrap">
{plan.name} <span className="text-2xl">
{plan.name}
</span>
{isCurrentPlan && isTrial && (
<Badge
variant="outlinePrimary"
className="text-xs"
>
{t("billingTrialBadge") ||
"Free Trial"}
</Badge>
)}
</div> </div>
<div className="mt-1"> <div className="mt-1">
<span className="text-xl"> <span className="text-xl">

View File

@@ -175,26 +175,6 @@ export default function GeneralPage() {
}, [variant]); }, [variant]);
useEffect(() => { useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
const loadIdp = async ( const loadIdp = async (
availableRoles: { roleId: number; name: string }[] availableRoles: { roleId: number; name: string }[]
) => { ) => {
@@ -520,6 +500,7 @@ export default function GeneralPage() {
onAutoProvisionChange={(checked) => { onAutoProvisionChange={(checked) => {
form.setValue("autoProvision", checked); form.setValue("autoProvision", checked);
}} }}
orgId={orgId as string}
roleMappingMode={roleMappingMode} roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => { onRoleMappingModeChange={(data) => {
setRoleMappingMode(data); setRoleMappingMode(data);

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,40 @@
"use client"; "use client";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel
FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Checkbox } from "@app/components/ui/checkbox"; import { useEnvContext } from "@app/hooks/useEnvContext";
import OrgRolesTagField from "@app/components/OrgRolesTagField"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { build } from "@server/build";
import { useEffect, useState } from "react"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
const accessControlsFormSchema = z.object({ const accessControlsFormSchema = z.object({
username: z.string(), username: z.string(),
@@ -59,12 +55,6 @@ export default function AccessControlsPage() {
const { orgId } = useParams(); const { orgId } = useParams();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac); const isPaid = isPaidUser(tierMatrix.fullRbac);
@@ -97,44 +87,21 @@ export default function AccessControlsPage() {
text: r.name text: r.name
})) }))
); );
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
fetchRoles();
form.setValue("autoProvisioned", user.autoProvisioned || false); form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []); }, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]);
const allRoleOptions = roles.map((role) => ({
id: role.roleId.toString(),
text: role.name
}));
const paywallMessage = const paywallMessage =
build === "saas" build === "saas"
? t("singleRolePerUserPlanNotice") ? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice"); : t("singleRolePerUserEditionNotice");
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) { const [, action, isSubmitting] = useActionState(onSubmit, null);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
if (values.roles.length === 0) { if (values.roles.length === 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -144,7 +111,6 @@ export default function AccessControlsPage() {
return; return;
} }
setLoading(true);
try { try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser const updateRoleRequest = supportsMultipleRolesPerUser
@@ -184,7 +150,6 @@ export default function AccessControlsPage() {
) )
}); });
} }
setLoading(false);
} }
return ( return (
@@ -203,7 +168,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} action={action}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
@@ -226,9 +191,7 @@ export default function AccessControlsPage() {
<OrgRolesTagField <OrgRolesTagField
form={form} form={form}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -236,9 +199,6 @@ export default function AccessControlsPage() {
showMultiRolePaywallMessage showMultiRolePaywallMessage
} }
paywallMessage={paywallMessage} paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/> />
{user.idpAutoProvision && ( {user.idpAutoProvision && (
@@ -277,8 +237,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
loading={loading} loading={isSubmitting}
disabled={loading} disabled={isSubmitting}
form="access-controls-form" form="access-controls-form"
> >
{t("accessControlsSubmit")} {t("accessControlsSubmit")}

View File

@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useState } from "react"; import { useActionState, useState } from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -91,7 +91,7 @@ export default function Page() {
"internal" "internal"
); );
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
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[]>([]);
@@ -311,10 +311,29 @@ export default function Page() {
setUserOptions(options); setUserOptions(options);
}, [idps, t]); }, [idps, t]);
async function onSubmitInternal( const [, submitInternalAction, isSubmittingInternal] = useActionState(
values: z.infer<typeof internalFormSchema> onSubmitInternal,
) { null
setLoading(true); );
const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState(
onSubmitGoogleAzure,
null
);
const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState(
onSubmitGenericOidc,
null
);
const loading =
isSubmittingInternal ||
isSubmittingGoogleAzure ||
isSubmittingGenericOidc;
async function onSubmitInternal() {
const isValid = await internalForm.trigger();
if (!isValid) return;
const values = internalForm.getValues();
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
@@ -357,25 +376,24 @@ export default function Page() {
setExpiresInDays(parseInt(values.validForHours) / 24); setExpiresInDays(parseInt(values.validForHours) / 24);
} }
setLoading(false);
} }
async function onSubmitGoogleAzure( async function onSubmitGoogleAzure() {
values: z.infer<typeof googleAzureFormSchema> const isValid = await googleAzureForm.trigger();
) { if (!isValid) return;
const values = googleAzureForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
.put(`/org/${orgId}/user`, { .put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure username: values.email,
email: values.email || undefined, email: values.email || undefined,
name: values.name, name: values.name,
type: "oidc", type: "oidc",
@@ -401,20 +419,19 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
setLoading(false);
} }
async function onSubmitGenericOidc( async function onSubmitGenericOidc() {
values: z.infer<typeof genericOidcFormSchema> const isValid = await genericOidcForm.trigger();
) { if (!isValid) return;
const values = genericOidcForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
@@ -445,8 +462,6 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
setLoading(false);
} }
return ( return (
@@ -513,9 +528,9 @@ export default function Page() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...internalForm}> <Form {...internalForm}>
<form <form
onSubmit={internalForm.handleSubmit( action={
onSubmitInternal submitInternalAction
)} }
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -595,13 +610,7 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={internalForm} form={internalForm}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -611,13 +620,6 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeInviteRoleTagIndex
}
setActiveTagIndex={
setActiveInviteRoleTagIndex
}
/> />
{env.email.emailEnabled && ( {env.email.emailEnabled && (
@@ -712,9 +714,9 @@ export default function Page() {
})() && ( })() && (
<Form {...googleAzureForm}> <Form {...googleAzureForm}>
<form <form
onSubmit={googleAzureForm.handleSubmit( action={
onSubmitGoogleAzure submitGoogleAzureAction
)} }
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -763,13 +765,7 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={googleAzureForm} form={googleAzureForm}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -779,13 +775,6 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>
@@ -808,9 +797,9 @@ export default function Page() {
})() && ( })() && (
<Form {...genericOidcForm}> <Form {...genericOidcForm}>
<form <form
onSubmit={genericOidcForm.handleSubmit( action={
onSubmitGenericOidc submitGenericOidcAction
)} }
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -888,13 +877,7 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={genericOidcForm} form={genericOidcForm}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -904,13 +887,6 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>

View File

@@ -3,10 +3,12 @@
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { defaultFormValues } from "@app/lib/alertRuleForm"; import { defaultFormValues } from "@app/lib/alertRuleForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect } from "react";
export default function NewAlertRulePage() { export default function NewAlertRulePage() {
const params = useParams(); const params = useParams();
@@ -14,6 +16,19 @@ export default function NewAlertRulePage() {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
const { env } = useEnvContext();
const router = useRouter();
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
useEffect(() => {
if (disableEnterpriseFeatures) {
router.replace(`/${orgId}/settings/alerting/rules`);
}
}, [disableEnterpriseFeatures, orgId, router]);
if (disableEnterpriseFeatures) {
return null;
}
return ( return (
<> <>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RolesSelector } from "@app/components/roles-selector";
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
import { import {
@@ -33,6 +34,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { UsersSelector } from "@app/components/users-selector";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
@@ -180,13 +182,6 @@ export default function ResourceAuthenticationPage() {
return []; return [];
}, [orgIdps]); }, [orgIdps]);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false); const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
useEffect(() => { useEffect(() => {
@@ -497,46 +492,27 @@ export default function ResourceAuthenticationPage() {
{t("roles")} {t("roles")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} selectedRoles={
activeTagIndex={ field.value ??
activeRolesTagIndex []
} }
setActiveTagIndex={ restrictAdminRole
setActiveRolesTagIndex orgId={
org.org
.orgId
} }
placeholder={t( onSelectRoles={(
"accessRoleSelect2" newUsers
)}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => { ) => {
usersRolesForm.setValue( usersRolesForm.setValue(
"roles", "roles",
newRoles as [ newUsers as [
Tag, Tag,
...Tag[] ...Tag[]
] ]
); );
}} }}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -557,23 +533,16 @@ export default function ResourceAuthenticationPage() {
{t("users")} {t("users")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<TagInput <UsersSelector
{...field} selectedUsers={
activeTagIndex={ field.value ??
activeUsersTagIndex []
} }
setActiveTagIndex={ orgId={
setActiveUsersTagIndex org.org
.orgId
} }
placeholder={t( onSelectUsers={(
"accessUserSelect"
)}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers newUsers
) => { ) => {
usersRolesForm.setValue( usersRolesForm.setValue(
@@ -584,19 +553,6 @@ export default function ResourceAuthenticationPage() {
] ]
); );
}} }}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -652,6 +652,8 @@ function ProxyResourceTargetsForm({
hcMode: null, hcMode: null,
hcUnhealthyInterval: null, hcUnhealthyInterval: null,
hcTlsServerName: null, hcTlsServerName: null,
hcHealthyThreshold: null,
hcUnhealthyThreshold: null,
siteType: sites.length > 0 ? sites[0].type : null, siteType: sites.length > 0 ? sites[0].type : null,
new: true, new: true,
updated: false updated: false
@@ -761,7 +763,9 @@ function ProxyResourceTargetsForm({
hcStatus: target.hcStatus || null, hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null, hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null, hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName hcTlsServerName: target.hcTlsServerName,
hcHealthyThreshold: target.hcHealthyThreshold || null,
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null
}; };
// Only include path-related fields for HTTP resources // Only include path-related fields for HTTP resources
@@ -1018,7 +1022,13 @@ function ProxyResourceTargetsForm({
30, 30,
hcTlsServerName: hcTlsServerName:
selectedTargetForHealthCheck.hcTlsServerName || selectedTargetForHealthCheck.hcTlsServerName ||
undefined undefined,
hcHealthyThreshold:
selectedTargetForHealthCheck.hcHealthyThreshold ||
1,
hcUnhealthyThreshold:
selectedTargetForHealthCheck.hcUnhealthyThreshold ||
1
}} }}
onChanges={async (config) => { onChanges={async (config) => {
if (selectedTargetForHealthCheck) { if (selectedTargetForHealthCheck) {

View File

@@ -303,6 +303,8 @@ export default function Page() {
hcMode: null, hcMode: null,
hcUnhealthyInterval: null, hcUnhealthyInterval: null,
hcTlsServerName: null, hcTlsServerName: null,
hcHealthyThreshold: null,
hcUnhealthyThreshold: null,
siteType: sites.length > 0 ? sites[0].type : null, siteType: sites.length > 0 ? sites[0].type : null,
new: true, new: true,
updated: false updated: false
@@ -552,7 +554,11 @@ export default function Page() {
hcUnhealthyInterval: hcUnhealthyInterval:
target.hcUnhealthyInterval || null, target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null, hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName hcTlsServerName: target.hcTlsServerName,
hcHealthyThreshold:
target.hcHealthyThreshold || null,
hcUnhealthyThreshold:
target.hcUnhealthyThreshold || null
}; };
// Only include path-related fields for HTTP resources // Only include path-related fields for HTTP resources
@@ -1520,7 +1526,13 @@ export default function Page() {
30, 30,
hcTlsServerName: hcTlsServerName:
selectedTargetForHealthCheck.hcTlsServerName || selectedTargetForHealthCheck.hcTlsServerName ||
undefined undefined,
hcHealthyThreshold:
selectedTargetForHealthCheck.hcHealthyThreshold ||
1,
hcUnhealthyThreshold:
selectedTargetForHealthCheck.hcUnhealthyThreshold ||
1
}} }}
onChanges={async (config) => { onChanges={async (config) => {
if (selectedTargetForHealthCheck) { if (selectedTargetForHealthCheck) {

View File

@@ -55,7 +55,9 @@ export default async function ProxyResourcesPage(
pagination = responseData.pagination; pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined); const siteIdParam = parsePositiveInt(
searchParams.get("siteId") ?? undefined
);
let initialFilterSite: { let initialFilterSite: {
siteId: number; siteId: number;
@@ -122,6 +124,7 @@ export default async function ProxyResourcesPage(
domainId: resource.domainId || undefined, domainId: resource.domainId || undefined,
fullDomain: resource.fullDomain ?? null, fullDomain: resource.fullDomain ?? null,
ssl: resource.ssl, ssl: resource.ssl,
wildcard: resource.wildcard,
targets: resource.targets?.map((target) => ({ targets: resource.targets?.map((target) => ({
targetId: target.targetId, targetId: target.targetId,
ip: target.ip, ip: target.ip,

View File

@@ -681,6 +681,9 @@ export default function PoliciesPage() {
control: form.control, control: form.control,
name: "orgMapping" name: "orgMapping"
}} }}
orgId={
editingPolicy?.orgId || policyFormOrgId
}
roleMappingFieldIdPrefix="admin-idp-policy-role" roleMappingFieldIdPrefix="admin-idp-policy-role"
roleMappingMode={policyRoleMappingMode} roleMappingMode={policyRoleMappingMode}
onRoleMappingModeChange={ onRoleMappingModeChange={

View File

@@ -212,16 +212,22 @@ export const orgNavSections = (
title: "sidebarManagement", title: "sidebarManagement",
icon: <Building2 className="size-4 flex-none" />, icon: <Building2 className="size-4 flex-none" />,
items: [ items: [
{ ...(!env?.flags.disableEnterpriseFeatures
title: "sidebarAlerting", ? [
href: "/{orgId}/settings/alerting", {
icon: <BellRing className="size-4 flex-none" /> title: "sidebarAlerting",
}, href: "/{orgId}/settings/alerting",
{ icon: (
title: "sidebarProvisioning", <BellRing className="size-4 flex-none" />
href: "/{orgId}/settings/provisioning", )
icon: <Boxes className="size-4 flex-none" /> },
}, {
title: "sidebarProvisioning",
href: "/{orgId}/settings/provisioning",
icon: <Boxes className="size-4 flex-none" />
}
]
: []),
{ {
title: "sidebarBluePrints", title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints", href: "/{orgId}/settings/blueprints",

View File

@@ -134,7 +134,9 @@ export default function AlertingRulesTable({
}: AlertingRulesTableProps) { }: AlertingRulesTableProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const envContext = useEnvContext();
const api = createApiClient(envContext);
const { env } = envContext;
const [isRefreshing, startRefresh] = useTransition(); const [isRefreshing, startRefresh] = useTransition();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
@@ -426,9 +428,15 @@ export default function AlertingRulesTable({
searchQuery={query} searchQuery={query}
manualFiltering manualFiltering
manualSorting manualSorting
onAdd={() => { onAdd={
router.push(`/${orgId}/settings/alerting/create`); !env.flags.disableEnterpriseFeatures
}} ? () => {
router.push(
`/${orgId}/settings/alerting/create`
);
}
: undefined
}
onRefresh={refreshList} onRefresh={refreshList}
isRefreshing={isRefreshing || isFiltering} isRefreshing={isRefreshing || isFiltering}
addButtonText={t("alertingAddRule")} addButtonText={t("alertingAddRule")}

View File

@@ -47,6 +47,7 @@ type AutoProvisionConfigWidgetProps = {
roleMappingFieldIdPrefix?: string; roleMappingFieldIdPrefix?: string;
showFreeformRoleNamesHint?: boolean; showFreeformRoleNamesHint?: boolean;
autoProvisionSwitchId?: string; autoProvisionSwitchId?: string;
orgId?: string;
}; };
export default function AutoProvisionConfigWidget({ export default function AutoProvisionConfigWidget({
@@ -67,7 +68,8 @@ export default function AutoProvisionConfigWidget({
showAutoProvisionSwitch = true, showAutoProvisionSwitch = true,
roleMappingFieldIdPrefix = "org-idp-auto-provision", roleMappingFieldIdPrefix = "org-idp-auto-provision",
showFreeformRoleNamesHint = false, showFreeformRoleNamesHint = false,
autoProvisionSwitchId = "auto-provision-toggle" autoProvisionSwitchId = "auto-provision-toggle",
orgId
}: AutoProvisionConfigWidgetProps) { }: AutoProvisionConfigWidgetProps) {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
@@ -106,6 +108,7 @@ export default function AutoProvisionConfigWidget({
showFreeformRoleNamesHint={ showFreeformRoleNamesHint={
showFreeformRoleNamesHint showFreeformRoleNamesHint
} }
orgId={orgId}
roleMappingMode={roleMappingMode} roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange} onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles} roles={roles}

View File

@@ -31,8 +31,9 @@ export function CertificateStatusContent({
const t = useTranslations(); const t = useTranslations();
const labelClass = const labelClass =
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-none"; "inline-flex shrink-0 items-center self-center text-sm font-medium leading-normal";
const valueClass = "inline-flex items-center gap-2 text-sm leading-none"; const valueClass =
"inline-flex items-center gap-2 text-sm leading-normal";
const handleRefresh = async () => { const handleRefresh = async () => {
await refreshCert(); await refreshCert();
@@ -133,14 +134,14 @@ export function CertificateStatusContent({
{isPending && !disableRestartButton ? ( {isPending && !disableRestartButton ? (
<Button <Button
variant="ghost" variant="ghost"
className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-none inline-flex items-center self-center" className="h-auto min-h-0 shrink-0 p-0 text-sm font-normal leading-normal inline-flex items-center self-center"
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing} disabled={refreshing}
title={t("restartCertificate", { title={t("restartCertificate", {
defaultValue: "Restart Certificate" defaultValue: "Restart Certificate"
})} })}
> >
<span className="inline-flex items-center gap-2 leading-none"> <span className="inline-flex items-center gap-2 leading-normal">
<FileBadge <FileBadge
className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`} className={`h-4 w-4 shrink-0 ${getStatusColor(cert.status)}`}
aria-hidden aria-hidden
@@ -148,7 +149,7 @@ export function CertificateStatusContent({
{cert.status.charAt(0).toUpperCase() + {cert.status.charAt(0).toUpperCase() +
cert.status.slice(1)} cert.status.slice(1)}
<RotateCw <RotateCw
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`} className={`h-4 w-4 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/> />
</span> </span>
</Button> </Button>
@@ -164,7 +165,7 @@ export function CertificateStatusContent({
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="inline-flex h-auto min-h-0 w-3 shrink-0 items-center justify-center self-center p-0" className="inline-flex h-4 w-4 min-h-0 shrink-0 items-center justify-center self-center p-0"
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing} disabled={refreshing}
title={t("restartCertificate", { title={t("restartCertificate", {
@@ -172,7 +173,7 @@ export function CertificateStatusContent({
})} })}
> >
<RotateCw <RotateCw
className={`h-3 w-3 shrink-0 ${refreshing ? "animate-spin" : ""}`} className={`h-4 w-4 shrink-0 ${refreshing ? "animate-spin" : ""}`}
/> />
</Button> </Button>
) : null} ) : null}

View File

@@ -33,7 +33,7 @@ const CopyToClipboard = ({
<div className="flex items-center space-x-2 min-w-0 max-w-full"> <div className="flex items-center space-x-2 min-w-0 max-w-full">
<button <button
type="button" type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0" className="h-4 w-4 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy} onClick={handleCopy}
> >
{!copied ? ( {!copied ? (

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaContent <CredenzaContent
className={cn( className={cn(
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0", "flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
className className
)} )}
{...props} {...props}

View File

@@ -13,6 +13,7 @@ type DismissableBannerProps = {
titleIcon: ReactNode; titleIcon: ReactNode;
description: string; description: string;
children?: ReactNode; children?: ReactNode;
dismissable?: boolean;
}; };
export const DismissableBanner = ({ export const DismissableBanner = ({
@@ -21,7 +22,8 @@ export const DismissableBanner = ({
title, title,
titleIcon, titleIcon,
description, description,
children children,
dismissable = true
}: DismissableBannerProps) => { }: DismissableBannerProps) => {
const [isDismissed, setIsDismissed] = useState(true); const [isDismissed, setIsDismissed] = useState(true);
const t = useTranslations(); const t = useTranslations();
@@ -66,19 +68,21 @@ export const DismissableBanner = ({
); );
}; };
if (isDismissed) { if (dismissable && isDismissed) {
return null; return null;
} }
return ( return (
<Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden"> <Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
<button {dismissable && (
onClick={handleDismiss} <button
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer" onClick={handleDismiss}
aria-label={t("dismiss")} className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
> aria-label={t("dismiss")}
<X className="w-4 h-4 text-muted-foreground" /> >
</button> <X className="w-4 h-4 text-muted-foreground" />
</button>
)}
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex flex-col lg:flex-row lg:items-center gap-6"> <div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1 space-y-2 min-w-0"> <div className="flex-1 space-y-2 min-w-0">

View File

@@ -104,7 +104,7 @@ export default function IdpLoginButtons({
</Alert> </Alert>
)} )}
<div className="space-y-2"> <div className="space-y-4">
{params.get("gotoapp") ? ( {params.get("gotoapp") ? (
<> <>
<Button <Button

View File

@@ -19,7 +19,7 @@ export function InfoSections({
return ( return (
<div <div
className={cn( className={cn(
"grid grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start", "grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
columnSizing === "content" && columnSizing === "content" &&
"md:justify-items-start md:justify-start" "md:justify-items-start md:justify-start"
)} )}
@@ -41,7 +41,11 @@ export function InfoSection({
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
}) { }) {
return <div className={cn("space-y-1", className)}>{children}</div>; return (
<div className={cn("min-w-0 w-full max-w-full space-y-1", className)}>
{children}
</div>
);
} }
export function InfoSectionTitle({ export function InfoSectionTitle({
@@ -51,7 +55,11 @@ export function InfoSectionTitle({
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
}) { }) {
return <div className={cn("font-semibold", className)}>{children}</div>; return (
<div className={cn("min-w-0 truncate font-semibold", className)}>
{children}
</div>
);
} }
export function InfoSectionContent({ export function InfoSectionContent({
@@ -62,8 +70,13 @@ export function InfoSectionContent({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("min-w-0 overflow-hidden", className)}> <div
<div className="w-full truncate [&>div.flex]:min-w-0 [&>div.flex]:!whitespace-normal [&>div.flex>span]:truncate [&>div.flex>a]:truncate"> className={cn(
"w-full min-w-0 max-w-full overflow-hidden",
className
)}
>
<div className="w-full min-w-0 max-w-full truncate [&>div.flex]:min-w-0 [&>div.flex]:!whitespace-normal [&>div.flex>span]:truncate [&>div.flex>a]:truncate">
{children} {children}
</div> </div>
</div> </div>

View File

@@ -40,7 +40,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChevronsUpDown, ExternalLink } from "lucide-react"; import {
ArrowDownIcon,
ChevronDownIcon,
ChevronsUpDown,
ExternalLink
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -50,11 +55,13 @@ import {
formatMultiSitesSelectorLabel formatMultiSitesSelectorLabel
} from "./multi-site-selector"; } from "./multi-site-selector";
import type { Selectedsite } from "./site-selector"; import type { Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus"; import CertificateStatus from "@app/components/CertificateStatus";
import { UsersSelector } from "./users-selector";
import { RolesSelector } from "./roles-selector";
import { build } from "@server/build"; import { build } from "@server/build";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -833,12 +840,16 @@ export function InternalResourceForm({
modeCidrKey modeCidrKey
) )
}, },
{ ...(!disableEnterpriseFeatures
value: "http", ? [
label: t( {
modeHttpKey value: "http" as const,
) label: t(
} modeHttpKey
)
}
]
: [])
]; ];
return ( return (
<FormItem> <FormItem>
@@ -1484,40 +1495,22 @@ export function InternalResourceForm({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel> <FormLabel>{t("roles")}</FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} selectedRoles={
activeTagIndex={ field.value ?? []
activeRolesTagIndex
} }
setActiveTagIndex={ orgId={orgId}
setActiveRolesTagIndex onSelectRoles={(
} newUsers
placeholder={t( ) => {
"accessRoleSelect2"
)}
size="sm"
tags={
form.getValues()
.roles ?? []
}
setTags={(newRoles) =>
form.setValue( form.setValue(
"roles", "roles",
newRoles as [ newUsers as [
Tag, Tag,
...Tag[] ...Tag[]
] ]
) );
} }}
enableAutocomplete
autocompleteOptions={
allRoles
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1530,43 +1523,21 @@ export function InternalResourceForm({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel> <FormLabel>{t("users")}</FormLabel>
<FormControl> <UsersSelector
<TagInput selectedUsers={
{...field} field.value ?? []
activeTagIndex={ }
activeUsersTagIndex orgId={orgId}
} onSelectUsers={(newUsers) => {
setActiveTagIndex={ form.setValue(
setActiveUsersTagIndex "users",
} newUsers as [
placeholder={t( Tag,
"accessUserSelect" ...Tag[]
)} ]
tags={ );
form.getValues() }}
.users ?? [] />
}
size="sm"
setTags={(newUsers) =>
form.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
)
}
enableAutocomplete={true}
autocompleteOptions={
allUsers
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -1580,73 +1551,20 @@ export function InternalResourceForm({
<FormLabel> <FormLabel>
{t("machineClients")} {t("machineClients")}
</FormLabel> </FormLabel>
<Popover> <MachinesSelector
<PopoverTrigger asChild> selectedMachines={
<FormControl> field.value ?? []
<Button }
variant="outline" orgId={orgId}
role="combobox" onSelectMachines={(
className={cn( machines
"justify-between w-full", ) => {
"text-muted-foreground pl-1.5" form.setValue(
)} "clients",
> machines
<span );
className={cn( }}
"inline-flex items-center gap-1", />
"overflow-x-auto"
)}
>
{(
field.value ??
[]
).map(
(
client
) => (
<span
key={
client.clientId
}
className={cn(
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
"py-1 px-1.5 text-xs"
)}
>
{
client.name
}
</span>
)
)}
<span className="pl-1 font-normal">
{t(
"accessClientSelect"
)}
</span>
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<MachinesSelector
selectedMachines={
field.value ??
[]
}
orgId={orgId}
onSelectMachines={(
machines
) => {
form.setValue(
"clients",
machines
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -129,9 +129,7 @@ export function LayoutSidebar({
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin); user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
const showTrial = const showTrial =
build === "saas" && build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
Boolean(orgId) &&
subscriptionContext?.isTrial;
return ( return (
<div <div
@@ -240,11 +238,16 @@ export function LayoutSidebar({
<div className="px-4"> <div className="px-4">
<ProductUpdates isCollapsed={isSidebarCollapsed} /> <ProductUpdates isCollapsed={isSidebarCollapsed} />
</div> </div>
) : <div className="mt-0.2"></div>} ) : (
<div className="mt-0.2"></div>
)}
{showTrial && ( {showTrial && (
<div className="px-4"> <div className="px-4">
<ShowTrialCard isCollapsed={isSidebarCollapsed} /> <ShowTrialCard
isCollapsed={isSidebarCollapsed}
isOwner={Boolean(currentOrg?.isOwner)}
/>
</div> </div>
)} )}

View File

@@ -368,7 +368,7 @@ export default function LoginForm({
{hasIdp && ( {hasIdp && (
<> <>
<div className="relative my-4"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<Separator /> <Separator />
</div> </div>

View File

@@ -40,6 +40,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import CopyToClipboard from "@app/components/CopyToClipboard";
// Update Resource type to include site information // Update Resource type to include site information
type Resource = { type Resource = {
@@ -64,6 +65,8 @@ type SiteResource = {
destination: string; destination: string;
mode: string; mode: string;
protocol: string | null; protocol: string | null;
ssl: boolean;
fullDomain: string | null;
enabled: boolean; enabled: boolean;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
@@ -123,6 +126,7 @@ const ResourceFavicon = ({
// Resource Info component // Resource Info component
const ResourceInfo = ({ resource }: { resource: Resource }) => { const ResourceInfo = ({ resource }: { resource: Resource }) => {
const t = useTranslations();
const hasAuthMethods = const hasAuthMethods =
resource.sso || resource.sso ||
resource.password || resource.password ||
@@ -141,7 +145,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
{/* Site Information */} {/* Site Information */}
{resource.siteName && ( {resource.siteName && (
<div> <div>
<div className="text-xs font-medium mb-1.5">Site</div> <div className="text-xs font-medium mb-1.5">
{t("site")}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Combine className="h-4 w-4 text-foreground shrink-0" /> <Combine className="h-4 w-4 text-foreground shrink-0" />
<span className="text-sm">{resource.siteName}</span> <span className="text-sm">{resource.siteName}</span>
@@ -157,7 +163,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
} }
> >
<div className="text-xs font-medium mb-1.5"> <div className="text-xs font-medium mb-1.5">
Authentication Methods {t("memberPortalAuthMethods")}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{resource.sso && ( {resource.sso && (
@@ -166,7 +172,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<Key className="h-3 w-3 text-blue-700 dark:text-blue-300" /> <Key className="h-3 w-3 text-blue-700 dark:text-blue-300" />
</div> </div>
<span className="text-sm"> <span className="text-sm">
Single Sign-On (SSO) {t("memberPortalSso")}
</span> </span>
</div> </div>
)} )}
@@ -176,7 +182,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" /> <KeyRound className="h-3 w-3 text-purple-700 dark:text-purple-300" />
</div> </div>
<span className="text-sm"> <span className="text-sm">
Password Protected {t("memberPortalPasswordProtected")}
</span> </span>
</div> </div>
)} )}
@@ -185,7 +191,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50"> <div className="h-5 w-5 rounded-full flex items-center justify-center bg-emerald-50/50 dark:bg-emerald-950/50">
<Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" /> <Fingerprint className="h-3 w-3 text-emerald-700 dark:text-emerald-300" />
</div> </div>
<span className="text-sm">PIN Code</span> <span className="text-sm">
{t("memberPortalPinCode")}
</span>
</div> </div>
)} )}
{resource.whitelist && ( {resource.whitelist && (
@@ -193,7 +201,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50"> <div className="h-5 w-5 rounded-full flex items-center justify-center bg-amber-50/50 dark:bg-amber-950/50">
<AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" /> <AtSign className="h-3 w-3 text-amber-700 dark:text-amber-300" />
</div> </div>
<span className="text-sm">Email Whitelist</span> <span className="text-sm">
{t("memberPortalEmailWhitelist")}
</span>
</div> </div>
)} )}
</div> </div>
@@ -208,7 +218,7 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" /> <AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive"> <span className="text-sm text-destructive">
Resource Disabled {t("memberPortalResourceDisabled")}
</span> </span>
</div> </div>
</div> </div>
@@ -233,6 +243,7 @@ const PaginationControls = ({
totalItems: number; totalItems: number;
itemsPerPage: number; itemsPerPage: number;
}) => { }) => {
const t = useTranslations();
const startItem = (currentPage - 1) * itemsPerPage + 1; const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems); const endItem = Math.min(currentPage * itemsPerPage, totalItems);
@@ -241,7 +252,11 @@ const PaginationControls = ({
return ( return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8"> <div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing {startItem}-{endItem} of {totalItems} resources {t("memberPortalShowingResources", {
start: startItem,
end: endItem,
total: totalItems
})}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -253,7 +268,7 @@ const PaginationControls = ({
className="gap-1" className="gap-1"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
Previous {t("memberPortalPrevious")}
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -309,7 +324,7 @@ const PaginationControls = ({
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="gap-1" className="gap-1"
> >
Next {t("memberPortalNext")}
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -389,13 +404,11 @@ export default function MemberResourcesPortal({
response.data.data.siteResources || [] response.data.data.siteResources || []
); );
} else { } else {
setError("Failed to load resources"); setError(t("memberPortalFailedToLoad"));
} }
} catch (err) { } catch (err) {
console.error("Error fetching user resources:", err); console.error("Error fetching user resources:", err);
setError( setError(t("memberPortalFailedToLoadDescription"));
"Failed to load resources. Please check your connection and try again."
);
} finally { } finally {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@@ -526,8 +539,8 @@ export default function MemberResourcesPortal({
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<SettingsSectionTitle <SettingsSectionTitle
title="Resources" title={t("memberPortalTitle")}
description="Resources you have access to in this organization" description={t("memberPortalDescription")}
/> />
{/* Search and Sort Controls - Skeleton */} {/* Search and Sort Controls - Skeleton */}
@@ -554,8 +567,8 @@ export default function MemberResourcesPortal({
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<SettingsSectionTitle <SettingsSectionTitle
title="Resources" title={t("memberPortalTitle")}
description="Resources you have access to in this organization" description={t("memberPortalDescription")}
/> />
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center"> <CardContent className="flex flex-col items-center justify-center py-20 text-center">
@@ -563,7 +576,7 @@ export default function MemberResourcesPortal({
<AlertCircle className="h-16 w-16 text-destructive/60" /> <AlertCircle className="h-16 w-16 text-destructive/60" />
</div> </div>
<h3 className="text-xl font-semibold text-foreground mb-3"> <h3 className="text-xl font-semibold text-foreground mb-3">
Unable to Load Resources {t("memberPortalUnableToLoad")}
</h3> </h3>
<p className="text-muted-foreground max-w-lg text-base mb-6"> <p className="text-muted-foreground max-w-lg text-base mb-6">
{error} {error}
@@ -574,7 +587,7 @@ export default function MemberResourcesPortal({
className="gap-2" className="gap-2"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
Try Again {t("memberPortalTryAgain")}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -585,8 +598,8 @@ export default function MemberResourcesPortal({
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<SettingsSectionTitle <SettingsSectionTitle
title="Resources" title={t("memberPortalTitle")}
description="Resources you have access to in this organization" description={t("memberPortalDescription")}
/> />
{/* Search and Sort Controls with Refresh */} {/* Search and Sort Controls with Refresh */}
@@ -595,7 +608,7 @@ export default function MemberResourcesPortal({
{/* Search */} {/* Search */}
<div className="relative w-full sm:w-80"> <div className="relative w-full sm:w-80">
<Input <Input
placeholder="Search resources..." placeholder={t("resourcesSearch")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 bg-card" className="w-full pl-8 bg-card"
@@ -607,26 +620,28 @@ export default function MemberResourcesPortal({
<div className="w-full sm:w-36"> <div className="w-full sm:w-36">
<Select value={sortBy} onValueChange={setSortBy}> <Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="bg-card"> <SelectTrigger className="bg-card">
<SelectValue placeholder="Sort by..." /> <SelectValue
placeholder={t("memberPortalSortBy")}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="name-asc"> <SelectItem value="name-asc">
Name A-Z {t("memberPortalSortNameAsc")}
</SelectItem> </SelectItem>
<SelectItem value="name-desc"> <SelectItem value="name-desc">
Name Z-A {t("memberPortalSortNameDesc")}
</SelectItem> </SelectItem>
<SelectItem value="domain-asc"> <SelectItem value="domain-asc">
Domain A-Z {t("memberPortalSortDomainAsc")}
</SelectItem> </SelectItem>
<SelectItem value="domain-desc"> <SelectItem value="domain-desc">
Domain Z-A {t("memberPortalSortDomainDesc")}
</SelectItem> </SelectItem>
<SelectItem value="status-enabled"> <SelectItem value="status-enabled">
Enabled First {t("memberPortalSortEnabledFirst")}
</SelectItem> </SelectItem>
<SelectItem value="status-disabled"> <SelectItem value="status-disabled">
Disabled First {t("memberPortalSortDisabledFirst")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -644,7 +659,7 @@ export default function MemberResourcesPortal({
<RefreshCw <RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/> />
Refresh {t("memberPortalRefresh")}
</Button> </Button>
</div> </div>
@@ -663,13 +678,15 @@ export default function MemberResourcesPortal({
</div> </div>
<h3 className="text-2xl font-semibold text-foreground mb-3"> <h3 className="text-2xl font-semibold text-foreground mb-3">
{searchQuery {searchQuery
? "No Resources Found" ? t("memberPortalNoResourcesFound")
: "No Resources Available"} : t("memberPortalNoResourcesAvailable")}
</h3> </h3>
<p className="text-muted-foreground max-w-lg text-base mb-6"> <p className="text-muted-foreground max-w-lg text-base mb-6">
{searchQuery {searchQuery
? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` ? t("memberPortalNoResourcesMatchSearch", {
: "You don't have access to any resources yet. Contact your administrator to get access to resources you need."} query: searchQuery
})
: t("memberPortalNoResourcesAccess")}
</p> </p>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
{searchQuery ? ( {searchQuery ? (
@@ -678,7 +695,7 @@ export default function MemberResourcesPortal({
variant="outline" variant="outline"
className="gap-2" className="gap-2"
> >
Clear Search {t("memberPortalClearSearch")}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -690,7 +707,7 @@ export default function MemberResourcesPortal({
<RefreshCw <RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/> />
Refresh Resources {t("memberPortalRefreshResources")}
</Button> </Button>
)} )}
</div> </div>
@@ -704,11 +721,12 @@ export default function MemberResourcesPortal({
<div className="mb-4"> <div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Globe className="h-5 w-5" /> <Globe className="h-5 w-5" />
Public Resources {t("memberPortalPublicResources")}
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Web applications and services accessible via {t(
browser "memberPortalPublicResourcesDescription"
)}
</p> </p>
</div> </div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -768,9 +786,12 @@ export default function MemberResourcesPortal({
resource.domain resource.domain
); );
toast({ toast({
title: "Copied to clipboard", title: t(
description: "memberPortalCopiedToClipboard"
"Resource URL has been copied to your clipboard.", ),
description: t(
"memberPortalCopiedUrlDescription"
),
duration: 2000 duration: 2000
}); });
}} }}
@@ -791,7 +812,7 @@ export default function MemberResourcesPortal({
disabled={!resource.enabled} disabled={!resource.enabled}
> >
<ExternalLink className="h-3.5 w-3.5 mr-2" /> <ExternalLink className="h-3.5 w-3.5 mr-2" />
Open Resource {t("memberPortalOpenResource")}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -806,11 +827,12 @@ export default function MemberResourcesPortal({
<div className="mb-4"> <div className="mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2"> <h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Combine className="h-5 w-5" /> <Combine className="h-5 w-5" />
Private Resources {t("memberPortalPrivateResources")}
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Internal network resources accessible via {t(
client "memberPortalPrivateResourcesDescription"
)}
</p> </p>
</div> </div>
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8"> <div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
@@ -843,11 +865,16 @@ export default function MemberResourcesPortal({
<InfoPopup> <InfoPopup>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5"> <div className="text-xs font-medium mb-1.5">
Resource Details {t(
"memberPortalResourceDetails"
)}
</div> </div>
<div> <div>
<span className="font-medium"> <span className="font-medium">
Mode: {t(
"memberPortalMode"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground capitalize"> <span className="ml-2 text-muted-foreground capitalize">
{ {
@@ -858,7 +885,10 @@ export default function MemberResourcesPortal({
{siteResource.protocol && ( {siteResource.protocol && (
<div> <div>
<span className="font-medium"> <span className="font-medium">
Protocol: {t(
"protocol"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground uppercase"> <span className="ml-2 text-muted-foreground uppercase">
{ {
@@ -869,7 +899,10 @@ export default function MemberResourcesPortal({
)} )}
<div> <div>
<span className="font-medium"> <span className="font-medium">
Destination: {t(
"memberPortalDestination"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
{ {
@@ -880,7 +913,10 @@ export default function MemberResourcesPortal({
{siteResource.alias && ( {siteResource.alias && (
<div> <div>
<span className="font-medium"> <span className="font-medium">
Alias: {t(
"memberPortalAlias"
)}
:
</span> </span>
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
{ {
@@ -891,14 +927,21 @@ export default function MemberResourcesPortal({
)} )}
<div> <div>
<span className="font-medium"> <span className="font-medium">
Status: {t(
"status"
)}
:
</span> </span>
<span <span
className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`} className={`ml-2 ${siteResource.enabled ? "text-green-600" : "text-red-600"}`}
> >
{siteResource.enabled {siteResource.enabled
? "Enabled" ? t(
: "Disabled"} "enabled"
)
: t(
"disabled"
)}
</span> </span>
</div> </div>
</div> </div>
@@ -907,7 +950,14 @@ export default function MemberResourcesPortal({
</div> </div>
<div className="mt-3"> <div className="mt-3">
{siteResource.alias ? ( {siteResource.mode === "http" &&
siteResource.fullDomain ? (
/* HTTP mode - show as clickable link */
<CopyToClipboard
text={`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`}
isLink={true}
/>
) : siteResource.alias ? (
<> <>
{/* Alias as primary */} {/* Alias as primary */}
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -925,9 +975,13 @@ export default function MemberResourcesPortal({
siteResource.alias! siteResource.alias!
); );
toast({ toast({
title: "Copied to clipboard", title: t(
"memberPortalCopiedToClipboard"
),
description: description:
"Resource alias has been copied to your clipboard.", t(
"memberPortalCopiedAliasDescription"
),
duration: 2000 duration: 2000
}); });
}} }}
@@ -959,9 +1013,13 @@ export default function MemberResourcesPortal({
siteResource.destination siteResource.destination
); );
toast({ toast({
title: "Copied to clipboard", title: t(
"memberPortalCopiedToClipboard"
),
description: description:
"Resource destination has been copied to your clipboard.", t(
"memberPortalCopiedDestinationDescription"
),
duration: 2000 duration: 2000
}); });
}} }}
@@ -973,10 +1031,34 @@ export default function MemberResourcesPortal({
</div> </div>
</div> </div>
<div className="p-6 pt-0 mt-auto"> <div className="p-6 pt-0 mt-auto space-y-2">
{siteResource.mode === "http" &&
siteResource.fullDomain ? (
<Button
onClick={() =>
window.open(
`${siteResource.ssl ? "https" : (siteResource.protocol ?? "http")}://${siteResource.fullDomain}`,
"_blank"
)
}
className="w-full h-9"
variant="outline"
size="sm"
disabled={
!siteResource.enabled
}
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
{t(
"memberPortalOpenResource"
)}
</Button>
) : null}
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground"> <div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
<Combine className="h-3.5 w-3.5 mr-2" /> <Combine className="h-3.5 w-3.5 mr-2" />
Requires Client Connection {t(
"memberPortalRequiresClientConnection"
)}
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -145,7 +145,7 @@ export default function MfaInputForm({
</Alert> </Alert>
)} )}
<div className="space-y-2"> <div className="space-y-4">
<Button <Button
type="submit" type="submit"
form={formId} form={formId}

View File

@@ -25,7 +25,6 @@ import {
import { import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
KeyRound,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -50,6 +49,7 @@ import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { Badge } from "@app/components/ui/badge";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
@@ -63,6 +63,61 @@ export type IdpRow = {
type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number]; type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number];
type ImportSourceOrg = { orgId: string; orgName: string };
type GroupedImportableIdp = {
idpId: number;
name: string;
type: string;
variant: string;
tags: string | null;
sources: ImportSourceOrg[];
};
function adminRowForImport(
group: GroupedImportableIdp,
source: ImportSourceOrg
): AdminIdpRow {
return {
idpId: group.idpId,
orgId: source.orgId,
orgName: source.orgName,
name: group.name,
type: group.type,
variant: group.variant,
tags: group.tags
};
}
function groupImportableIdps(rows: AdminIdpRow[]): GroupedImportableIdp[] {
const map = new Map<number, GroupedImportableIdp>();
for (const row of rows) {
let g = map.get(row.idpId);
if (!g) {
g = {
idpId: row.idpId,
name: row.name,
type: row.type,
variant: row.variant,
tags: row.tags,
sources: []
};
map.set(row.idpId, g);
}
if (!g.sources.some((s) => s.orgId === row.orgId)) {
g.sources.push({ orgId: row.orgId, orgName: row.orgName });
}
}
return Array.from(map.values())
.map((item) => ({
...item,
sources: [...item.sources].sort((a, b) =>
a.orgName.localeCompare(b.orgName)
)
}))
.sort((a, b) => b.name.localeCompare(a.name));
}
function IdpImportRowIcon({ function IdpImportRowIcon({
type, type,
variant variant
@@ -114,16 +169,22 @@ export default function IdpTable({ idps, orgId }: Props) {
); );
}, [adminIdpsRaw, orgId, idps]); }, [adminIdpsRaw, orgId, idps]);
const shownImportIdps = useMemo(() => { const importableGrouped = useMemo(
() => groupImportableIdps(importableIdps),
[importableIdps]
);
const shownImportGrouped = useMemo(() => {
const q = debouncedImportSearch.trim().toLowerCase(); const q = debouncedImportSearch.trim().toLowerCase();
if (!q) { if (!q) {
return importableIdps; return importableGrouped;
} }
return importableIdps.filter((row) => { return importableGrouped.filter((group) => {
const hay = `${row.orgName} ${row.name}`.toLowerCase(); const hay =
`${group.name} ${group.sources.map((s) => s.orgName).join(" ")}`.toLowerCase();
return hay.includes(q); return hay.includes(q);
}); });
}, [importableIdps, debouncedImportSearch]); }, [importableGrouped, debouncedImportSearch]);
const deleteIdp = async (idpId: number) => { const deleteIdp = async (idpId: number) => {
try { try {
@@ -364,31 +425,44 @@ export default function IdpTable({ idps, orgId }: Props) {
{t("idpImportEmpty")} {t("idpImportEmpty")}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{shownImportIdps.map((row) => ( {shownImportGrouped.map((group) => (
<CommandItem <CommandItem
key={`${row.idpId}:${row.orgId}`} key={group.idpId}
className="items-start gap-3 py-2.5" className="items-start gap-3 py-2.5"
value={`${row.idpId}:${row.orgId}:${row.orgName}:${row.name}`} value={`${group.idpId}:${group.name}:${group.sources.map((s) => s.orgName).join(" ")}`}
disabled={!canImportOrgOidcIdp} disabled={!canImportOrgOidcIdp}
onSelect={() => { onSelect={() => {
if (!canImportOrgOidcIdp) { if (!canImportOrgOidcIdp) {
return; return;
} }
void importIdp(row); void importIdp(
adminRowForImport(
group,
group.sources[0]
)
);
}} }}
> >
<div className="mt-0.5 shrink-0"> <div className="mt-0.5 shrink-0">
<IdpImportRowIcon <IdpImportRowIcon
type={row.type} type={group.type}
variant={row.variant} variant={group.variant}
/> />
</div> </div>
<div className="min-w-0 flex-1 text-left"> <div className="min-w-0 flex-1 text-left">
<div className="truncate font-medium leading-tight"> <div className="truncate font-medium leading-tight">
{row.orgName} {group.name}
</div> </div>
<div className="truncate text-sm leading-tight text-muted-foreground"> <div className="mt-1 flex flex-wrap gap-1">
{row.name} {group.sources.map((src) => (
<Badge
key={src.orgId}
variant="secondary"
className="max-w-full truncate font-normal"
>
{src.orgName}
</Badge>
))}
</div> </div>
</div> </div>
</CommandItem> </CommandItem>

View File

@@ -8,51 +8,42 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Dispatch, SetStateAction } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
export type RoleTag = { import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
id: string; import { RolesSelector, type SelectedRole } from "./roles-selector";
text: string;
};
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = { type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">; form: Pick<
UseFormReturn<TFieldValues>,
"control" | "getValues" | "setValue"
>;
orgId: string;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
name?: Path<TFieldValues>; name?: Path<TFieldValues>;
label: string; label?: string;
placeholder: string;
allRoleOptions: Tag[];
supportsMultipleRolesPerUser: boolean; supportsMultipleRolesPerUser: boolean;
showMultiRolePaywallMessage: boolean; showMultiRolePaywallMessage: boolean;
paywallMessage: string; paywallMessage: string;
loading?: boolean; disabled?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
}; };
export default function OrgRolesTagField<TFieldValues extends FieldValues>({ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form, form,
name = "roles" as Path<TFieldValues>, name = "roles" as Path<TFieldValues>,
label, label,
placeholder, orgId,
allRoleOptions,
supportsMultipleRolesPerUser, supportsMultipleRolesPerUser,
showMultiRolePaywallMessage, showMultiRolePaywallMessage,
paywallMessage, paywallMessage,
loading = false, disabled
activeTagIndex,
setActiveTagIndex
}: OrgRolesTagFieldProps<TFieldValues>) { }: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations(); const t = useTranslations();
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) { function setRoleTags(nextValue: SelectedRole[]) {
const prev = form.getValues(name) as Tag[]; const prev = form.getValues(name) as SelectedRole[];
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser const next = supportsMultipleRolesPerUser
? nextValue ? nextValue
: nextValue.length > 1 : nextValue.length > 1
@@ -88,22 +79,13 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
name={name} name={name}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{label}</FormLabel> <FormLabel>{label ?? t("roles")}</FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} orgId={orgId}
activeTagIndex={activeTagIndex} selectedRoles={field.value ?? []}
setActiveTagIndex={setActiveTagIndex} onSelectRoles={setRoleTags}
placeholder={placeholder} disabled={disabled}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={allRoleOptions}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
disabled={loading}
/> />
</FormControl> </FormControl>
{showMultiRolePaywallMessage && ( {showMultiRolePaywallMessage && (

View File

@@ -96,6 +96,7 @@ export type ResourceRow = {
targets?: TargetHealth[]; targets?: TargetHealth[];
health?: "healthy" | "degraded" | "unhealthy" | "unknown"; health?: "healthy" | "degraded" | "unhealthy" | "unknown";
sites: ResourceSiteRow[]; sites: ResourceSiteRow[];
wildcard?: boolean;
}; };
function StatusIcon({ function StatusIcon({
@@ -570,10 +571,14 @@ export default function ProxyResourcesTable({
/> />
) : null} ) : null}
<div className=""> <div className="">
<CopyToClipboard {!resourceRow.wildcard ? (
text={resourceRow.domain} <CopyToClipboard
isLink={true} text={resourceRow.domain}
/> isLink={true}
/>
) : (
<span>{resourceRow.domain}</span>
)}
</div> </div>
</div> </div>
); );

View File

@@ -528,7 +528,7 @@ export default function ResetPasswordForm({
)} )}
{state === "request" && ( {state === "request" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
{env.email.emailEnabled && ( {env.email.emailEnabled && (
<Button <Button
type="submit" type="submit"

View File

@@ -40,7 +40,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle> <InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{resource.niceId} <span className="inline-flex items-center">
{resource.niceId}
</span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{resource.http ? ( {resource.http ? (
@@ -49,7 +51,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSectionTitle>URL</InfoSectionTitle> <InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{resource.wildcard ? ( {resource.wildcard ? (
<span>{fullUrl}</span> <span className="inline-flex items-center">
{fullUrl}
</span>
) : ( ) : (
<CopyToClipboard <CopyToClipboard
text={fullUrl} text={fullUrl}
@@ -68,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.sso || authInfo.sso ||
authInfo.whitelist || authInfo.whitelist ||
authInfo.headerAuth ? ( authInfo.headerAuth ? (
<div className="flex items-start space-x-2"> <div className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" /> <ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("protected")}</span> <span>{t("protected")}</span>
</div> </div>
@@ -106,7 +110,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{t("protocol")} {t("protocol")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{resource.protocol.toUpperCase()} <span className="inline-flex items-center">
{resource.protocol.toUpperCase()}
</span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>

View File

@@ -16,6 +16,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import { RolesSelector } from "./roles-selector";
export type RoleMappingRoleOption = { export type RoleMappingRoleOption = {
roleId: number; roleId: number;
@@ -38,6 +39,8 @@ export type RoleMappingConfigFieldsProps = {
fieldIdPrefix?: string; fieldIdPrefix?: string;
/** When true, show extra hint for global default policies (no org role list). */ /** When true, show extra hint for global default policies (no org role list). */
showFreeformRoleNamesHint?: boolean; showFreeformRoleNamesHint?: boolean;
/** Org ID to use for role lookup. Falls back to URL params when not provided. */
orgId?: string;
}; };
export default function RoleMappingConfigFields({ export default function RoleMappingConfigFields({
@@ -53,14 +56,12 @@ export default function RoleMappingConfigFields({
rawExpression, rawExpression,
onRawExpressionChange, onRawExpressionChange,
fieldIdPrefix = "role-mapping", fieldIdPrefix = "role-mapping",
showFreeformRoleNamesHint = false showFreeformRoleNamesHint = false,
orgId
}: RoleMappingConfigFieldsProps) { }: RoleMappingConfigFieldsProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac); const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
const showSingleRoleDisclaimer = const showSingleRoleDisclaimer =
@@ -94,6 +95,10 @@ export default function RoleMappingConfigFields({
} }
}, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]); }, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
const [fixedRolesActiveTagIndex, setFixedRolesActiveTagIndex] = useState<
number | null
>(null);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`; const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`; const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
@@ -160,58 +165,94 @@ export default function RoleMappingConfigFields({
{roleMappingMode === "fixedRoles" && ( {roleMappingMode === "fixedRoles" && (
<div className="space-y-2 min-w-0 max-w-full"> <div className="space-y-2 min-w-0 max-w-full">
<TagInput {restrictToOrgRoles ? (
tags={fixedRoleNames.map((name) => ({ <RolesSelector
id: name, selectedRoles={fixedRoleNames.map((name) => ({
text: name
}))}
setTags={(nextTags) => {
const prevTags = fixedRoleNames.map((name) => ({
id: name, id: name,
text: name text: name
})); }))}
const next = mapRolesByName
typeof nextTags === "function" orgId={orgId as string}
? nextTags(prevTags) onSelectRoles={(nextTags) => {
: nextTags; let names = [
...new Set(nextTags.map((tag) => tag.text))
];
let names = [ if (!supportsMultipleRolesPerUser) {
...new Set(next.map((tag) => tag.text)) if (
]; names.length === 0 &&
fixedRoleNames.length > 0
if (!supportsMultipleRolesPerUser) { ) {
if ( onFixedRoleNamesChange([
names.length === 0 && fixedRoleNames[
fixedRoleNames.length > 0 fixedRoleNames.length - 1
) { ]!
onFixedRoleNamesChange([ ]);
fixedRoleNames[ return;
fixedRoleNames.length - 1 }
]! if (names.length > 1) {
]); names = [names[names.length - 1]!];
return; }
} }
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onFixedRoleNamesChange(names); onFixedRoleNamesChange(names);
}} }}
activeTagIndex={activeFixedRoleTagIndex} />
setActiveTagIndex={setActiveFixedRoleTagIndex} ) : (
placeholder={ <TagInput
restrictToOrgRoles tags={fixedRoleNames.map((name) => ({
? t("roleMappingFixedRolesPlaceholderSelect") id: name,
: t("roleMappingFixedRolesPlaceholderFreeform") text: name
} }))}
enableAutocomplete={restrictToOrgRoles} setTags={(nextTags) => {
autocompleteOptions={roleOptions} const prev = fixedRoleNames.map((name) => ({
restrictTagsToAutocompleteOptions={restrictToOrgRoles} id: name,
allowDuplicates={false} text: name
sortTags={true} }));
size="sm" const next =
/> typeof nextTags === "function"
? nextTags(prev)
: nextTags;
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
fixedRoleNames.length > 0
) {
onFixedRoleNamesChange([
fixedRoleNames[
fixedRoleNames.length - 1
]!
]);
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onFixedRoleNamesChange(names);
}}
activeTagIndex={fixedRolesActiveTagIndex}
setActiveTagIndex={setFixedRolesActiveTagIndex}
placeholder={t(
"roleMappingAssignRolesPlaceholderFreeform"
)}
enableAutocomplete={false}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={false}
allowDuplicates={false}
sortTags={true}
size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/>
)}
<FormDescription> <FormDescription>
{showFreeformRoleNamesHint {showFreeformRoleNamesHint
? t("roleMappingFixedRolesDescriptionDefaultPolicy") ? t("roleMappingFixedRolesDescriptionDefaultPolicy")
@@ -261,6 +302,7 @@ export default function RoleMappingConfigFields({
showFreeformRoleNamesHint={ showFreeformRoleNamesHint={
showFreeformRoleNamesHint showFreeformRoleNamesHint
} }
orgId={orgId}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -337,7 +379,8 @@ function BuilderRuleRow({
supportsMultipleRolesPerUser, supportsMultipleRolesPerUser,
showRemoveButton, showRemoveButton,
onChange, onChange,
onRemove onRemove,
orgId
}: { }: {
rule: MappingBuilderRule; rule: MappingBuilderRule;
roleOptions: Tag[]; roleOptions: Tag[];
@@ -349,6 +392,7 @@ function BuilderRuleRow({
showRemoveButton: boolean; showRemoveButton: boolean;
onChange: (rule: MappingBuilderRule) => void; onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void; onRemove: () => void;
orgId?: string;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null); const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
@@ -378,67 +422,109 @@ function BuilderRuleRow({
{t("roleMappingAssignRoles")} {t("roleMappingAssignRoles")}
</FormLabel> </FormLabel>
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
<TagInput {restrictToOrgRoles ? (
tags={rule.roleNames.map((name) => ({ <RolesSelector
id: name, selectedRoles={rule.roleNames.map((name) => ({
text: name
}))}
setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map((name) => ({
id: name, id: name,
text: name text: name
})); }))}
const next = buttonText={t("roleMappingAssignRoles")}
typeof nextTags === "function" mapRolesByName
? nextTags(prevRoleTags) orgId={orgId as string}
: nextTags; onSelectRoles={(nextTags) => {
let names = [
...new Set(nextTags.map((tag) => tag.text))
];
let names = [ if (!supportsMultipleRolesPerUser) {
...new Set(next.map((tag) => tag.text)) if (
]; names.length === 0 &&
rule.roleNames.length > 0
if (!supportsMultipleRolesPerUser) { ) {
if ( onChange({
names.length === 0 && ...rule,
rule.roleNames.length > 0 roleNames: [
) { rule.roleNames[
onChange({ rule.roleNames.length - 1
...rule, ]!
roleNames: [ ]
rule.roleNames[ });
rule.roleNames.length - 1 return;
]! }
] if (names.length > 1) {
}); names = [names[names.length - 1]!];
return; }
} }
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onChange({ onChange({
...rule, ...rule,
roleNames: names roleNames: names
}); });
}} }}
activeTagIndex={activeTagIndex} />
setActiveTagIndex={setActiveTagIndex} ) : (
placeholder={ <TagInput
restrictToOrgRoles tags={rule.roleNames.map((name) => ({
? t("roleMappingAssignRoles") id: name,
: t("roleMappingAssignRolesPlaceholderFreeform") text: name
} }))}
enableAutocomplete={restrictToOrgRoles} setTags={(nextTags) => {
autocompleteOptions={roleOptions} const prevRoleTags = rule.roleNames.map(
restrictTagsToAutocompleteOptions={restrictToOrgRoles} (name) => ({
allowDuplicates={false} id: name,
sortTags={true} text: name
size="sm" })
styleClasses={{ );
inlineTagsContainer: "min-w-0 max-w-full" const next =
}} typeof nextTags === "function"
/> ? nextTags(prevRoleTags)
: nextTags;
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
rule.roleNames.length > 0
) {
onChange({
...rule,
roleNames: [
rule.roleNames[
rule.roleNames.length - 1
]!
]
});
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onChange({
...rule,
roleNames: names
});
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={t(
"roleMappingAssignRolesPlaceholderFreeform"
)}
enableAutocomplete={false}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={false}
allowDuplicates={false}
sortTags={true}
size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/>
)}
</div> </div>
{showFreeformRoleNamesHint && ( {showFreeformRoleNamesHint && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -17,9 +17,11 @@ import { useTranslations } from "next-intl";
const TRIAL_DURATION_DAYS = 10; const TRIAL_DURATION_DAYS = 10;
export default function ShowTrialCard({ export default function ShowTrialCard({
isCollapsed isCollapsed,
isOwner = false
}: { }: {
isCollapsed?: boolean; isCollapsed?: boolean;
isOwner?: boolean;
}) { }) {
const context = useSubscriptionStatusContext(); const context = useSubscriptionStatusContext();
const params = useParams(); const params = useParams();
@@ -32,53 +34,55 @@ export default function ShowTrialCard({
const now = Date.now(); const now = Date.now();
const remainingMs = trialExpiresAt - now; const remainingMs = trialExpiresAt - now;
const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24))); const remainingDays = Math.max(
0,
Math.ceil(remainingMs / (1000 * 60 * 60 * 24))
);
const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000; const totalMs = TRIAL_DURATION_DAYS * 24 * 60 * 60 * 1000;
const progressPct = Math.min(100, Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100)); const progressPct = Math.min(
100,
Math.max(0, ((now - (trialExpiresAt - totalMs)) / totalMs) * 100)
);
// Inverted: full bar at start, drains to empty as trial ends // Inverted: full bar at start, drains to empty as trial ends
const displayPct = 100 - progressPct; const displayPct = 100 - progressPct;
const billingHref = orgId ? `/${orgId}/settings/billing` : "/"; const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
if (isCollapsed) { if (isCollapsed) {
return ( const icon = (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <span className="flex items-center justify-center rounded-md p-2 text-muted-foreground">
href={billingHref}
className="flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
>
<ClockIcon className="h-4 w-4 flex-none" /> <ClockIcon className="h-4 w-4 flex-none" />
</Link> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" sideOffset={8}> <TooltipContent side="right" sideOffset={8}>
<p> <p>
{remainingDays === 0 {remainingDays === 0
? t("trialExpired") ? t("trialExpired")
: t("trialDaysLeftShort", { days: remainingDays })} : t("trialDaysLeftShort", {
days: remainingDays
})}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
if (isOwner) {
return <Link href={billingHref}>{icon}</Link>;
}
return icon;
} }
return ( const cardContent = (
<Link <>
href={billingHref}
className={cn(
"group cursor-pointer block",
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm",
"transition duration-200 ease-in-out hover:bg-secondary/80 dark:hover:bg-secondary/60"
)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ClockIcon className="flex-none size-4 text-muted-foreground" /> <ClockIcon className="flex-none size-4 text-muted-foreground" />
<p className="font-medium flex-1 leading-tight"> <p className="font-medium flex-1 leading-tight">
{remainingDays === 0 {remainingDays === 0 ? t("trialExpired") : t("trialActive")}
? t("trialExpired")
: t("trialActive")}
</p> </p>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -88,11 +92,37 @@ export default function ShowTrialCard({
? t("trialHasEnded") ? t("trialHasEnded")
: t("trialDaysRemaining", { count: remainingDays })} : t("trialDaysRemaining", { count: remainingDays })}
</small> </small>
<div className="inline-flex items-center gap-1 text-xs text-muted-foreground group-hover:text-foreground transition-colors"> {isOwner && (
<span>{t("trialGoToBilling")}</span> <div className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<ArrowRight className="flex-none size-3" /> <span>{t("trialGoToBilling")}</span>
</div> <ArrowRight className="flex-none size-3" />
</div>
)}
</div> </div>
</Link> </>
);
if (isOwner) {
return (
<Link
href={billingHref}
className={cn(
"group cursor-pointer block",
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
)}
>
{cardContent}
</Link>
);
}
return (
<div
className={cn(
"rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm"
)}
>
{cardContent}
</div>
); );
} }

View File

@@ -22,7 +22,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { LookupUserResponse } from "@server/routers/auth/lookupUser"; import { LookupUserResponse } from "@server/routers/auth/lookupUser";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import LoginPasswordForm from "@app/components/LoginPasswordForm"; import LoginPasswordForm from "@app/components/LoginPasswordForm";
import LoginOrgSelector from "@app/components/LoginOrgSelector"; import SmartLoginOrgSelector from "@app/components/SmartLoginOrgSelector";
import UserProfileCard from "@app/components/UserProfileCard"; import UserProfileCard from "@app/components/UserProfileCard";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
@@ -206,7 +206,7 @@ export default function SmartLoginForm({
if (viewState.type === "orgSelector") { if (viewState.type === "orgSelector") {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<LoginOrgSelector <SmartLoginOrgSelector
identifier={viewState.identifier} identifier={viewState.identifier}
lookupResult={viewState.lookupResult} lookupResult={viewState.lookupResult}
redirect={redirect} redirect={redirect}
@@ -284,7 +284,7 @@ export default function SmartLoginForm({
{orgSignIn && ( {orgSignIn && (
<> <>
<div className="relative my-4"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<Separator /> <Separator />
</div> </div>

View File

@@ -0,0 +1,297 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import LoginPasswordForm from "@app/components/LoginPasswordForm";
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
import UserProfileCard from "@app/components/UserProfileCard";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { generateOidcUrlProxy } from "@app/actions/server";
import {
redirect as redirectTo,
useRouter,
useSearchParams
} from "next/navigation";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Separator } from "@app/components/ui/separator";
type SmartLoginOrgSelectorProps = {
identifier: string;
lookupResult: LookupUserResponse;
redirect?: string;
forceLogin?: boolean;
onUseDifferentAccount?: () => void;
};
type OrgBucket = {
orgId: string;
orgName: string;
idps: Array<{
idpId: number;
name: string;
variant: string | null;
}>;
hasInternalAuth: boolean;
};
type GroupedLoginIdp = {
idpId: number;
name: string;
variant: string | null;
orgs: { orgId: string; orgName: string }[];
};
function buildOrgMap(lookupResult: LookupUserResponse) {
const orgMap = new Map<string, OrgBucket>();
for (const account of lookupResult.accounts) {
for (const org of account.orgs) {
if (!orgMap.has(org.orgId)) {
orgMap.set(org.orgId, {
orgId: org.orgId,
orgName: org.orgName,
idps: org.idps,
hasInternalAuth: org.hasInternalAuth
});
} else {
const existing = orgMap.get(org.orgId)!;
const existingIdpIds = new Set(
existing.idps.map((i) => i.idpId)
);
for (const idp of org.idps) {
if (!existingIdpIds.has(idp.idpId)) {
existing.idps.push(idp);
}
}
if (org.hasInternalAuth) {
existing.hasInternalAuth = true;
}
}
}
}
return Array.from(orgMap.values());
}
function groupIdpsAcrossOrgs(orgs: OrgBucket[]): GroupedLoginIdp[] {
const map = new Map<number, GroupedLoginIdp>();
for (const org of orgs) {
for (const idp of org.idps) {
let g = map.get(idp.idpId);
if (!g) {
g = {
idpId: idp.idpId,
name: idp.name,
variant: idp.variant,
orgs: []
};
map.set(idp.idpId, g);
}
if (!g.orgs.some((o) => o.orgId === org.orgId)) {
g.orgs.push({ orgId: org.orgId, orgName: org.orgName });
}
}
}
return Array.from(map.values())
.map((g) => ({
...g,
orgs: [...g.orgs].sort((a, b) => a.orgName.localeCompare(b.orgName))
}))
.sort((a, b) => b.name.localeCompare(a.name));
}
export default function SmartLoginOrgSelector({
identifier,
lookupResult,
redirect,
forceLogin,
onUseDifferentAccount
}: SmartLoginOrgSelectorProps) {
const t = useTranslations();
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingIdpId, setPendingIdpId] = useState<number | null>(null);
const params = useSearchParams();
const router = useRouter();
const orgs = buildOrgMap(lookupResult);
const groupedIdps = groupIdpsAcrossOrgs(orgs);
const hasInternalAccount = lookupResult.accounts.some(
(acc) => acc.hasInternalAuth
);
function goToApp() {
const url = window.location.href.split("?")[0];
router.push(url);
}
useEffect(() => {
if (params.get("gotoapp")) {
goToApp();
}
}, []);
async function loginWithIdp(idpId: number, orgId: string) {
setPendingIdpId(idpId);
setError(null);
let redirectToUrl: string | undefined;
try {
const safeRedirect = cleanRedirect(redirect || "/");
const response = await generateOidcUrlProxy(
idpId,
safeRedirect,
undefined,
forceLogin
);
if (response.error) {
setError(response.message);
setPendingIdpId(null);
return;
}
const data = response.data;
if (data?.redirectUrl) {
redirectToUrl = data.redirectUrl;
}
} catch {
setError(
t("loginError", {
defaultValue:
"An unexpected error occurred. Please try again."
})
);
}
if (redirectToUrl) {
redirectTo(redirectToUrl);
} else {
setPendingIdpId(null);
}
}
if (showPasswordForm) {
return (
<div className="space-y-4">
<UserProfileCard
identifier={identifier}
description={t("loginSelectAuthenticationMethod")}
onUseDifferentAccount={onUseDifferentAccount}
useDifferentAccountText={t(
"deviceLoginUseDifferentAccount"
)}
/>
<LoginPasswordForm
identifier={identifier}
redirect={redirect}
forceLogin={forceLogin}
/>
</div>
);
}
return (
<div>
<UserProfileCard
identifier={identifier}
description={t("loginSelectAuthenticationMethod")}
onUseDifferentAccount={onUseDifferentAccount}
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
/>
{hasInternalAccount && (
<div className="mt-4">
<Button
type="button"
className="w-full"
onClick={() => setShowPasswordForm(true)}
>
{t("signInWithPassword")}
</Button>
</div>
)}
{groupedIdps.length > 0 ? (
<div className="mt-3 space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
{t("idpContinue")}
</span>
</div>
</div>
<div className="space-y-4">
{params.get("gotoapp") ? (
<Button
type="button"
className="w-full"
onClick={() => {
goToApp();
}}
>
{t("continueToApplication")}
</Button>
) : (
groupedIdps.map((group) => {
const effectiveType =
group.variant || group.name.toLowerCase();
const sourceOrgId = group.orgs[0].orgId;
return (
<Button
key={group.idpId}
type="button"
variant="outline"
className="h-auto w-full flex flex-wrap items-center justify-start gap-x-2 gap-y-1.5 py-3 text-left"
onClick={() => {
void loginWithIdp(
group.idpId,
sourceOrgId
);
}}
disabled={pendingIdpId !== null}
>
<IdpTypeIcon
type={effectiveType}
size={16}
className="shrink-0"
/>
<span className="font-medium shrink-0">
{group.name}
</span>
{group.orgs.map((org) => (
<Badge
key={org.orgId}
variant="secondary"
className="max-w-full shrink-0 truncate font-normal"
>
{org.orgName}
</Badge>
))}
</Button>
);
})
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { Button } from "@app/components/ui/button";
import { ClockIcon, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import DismissableBanner from "./DismissableBanner";
type TrialBillingBannerProps = {
onUpgrade: () => void;
};
export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="trial-billing-banner-dismissed"
version={1}
title={t("billingTrialBannerTitle")}
titleIcon={<ClockIcon className="w-5 h-5 text-primary" />}
description={t("billingTrialBannerDescription")}
dismissable={false}
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
onClick={onUpgrade}
>
{t("billingTrialBannerUpgrade")}
<ArrowRight className="w-4 h-4" />
</Button>
</DismissableBanner>
);
};
export default TrialBillingBanner;

View File

@@ -1,18 +1,5 @@
"use client"; "use client";
import { useState, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { BellPlus, BellRing } from "lucide-react";
import {
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody
} from "@app/components/Settings";
import UptimeBar from "@app/components/UptimeBar";
import { Button } from "@app/components/ui/button";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@@ -23,18 +10,32 @@ import {
CredenzaHeader, CredenzaHeader,
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import UptimeBar from "@app/components/UptimeBar";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { orgQueries } from "@app/lib/queries";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BellPlus, BellRing } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
import { RolesSelector } from "./roles-selector";
import { UsersSelector } from "./users-selector";
interface UptimeAlertSectionProps { interface UptimeAlertSectionProps {
orgId: string; orgId: string;
@@ -52,10 +53,12 @@ export default function UptimeAlertSection({
days = 90 days = 90
}: UptimeAlertSectionProps) { }: UptimeAlertSectionProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const envContext = useEnvContext();
const api = createApiClient(envContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
const { env } = envContext;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState( const [name, setName] = useState(
@@ -64,12 +67,7 @@ export default function UptimeAlertSection({
const [userTags, setUserTags] = useState<Tag[]>([]); const [userTags, setUserTags] = useState<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]); const [roleTags, setRoleTags] = useState<Tag[]>([]);
const [emailTags, setEmailTags] = useState<Tag[]>([]); const [emailTags, setEmailTags] = useState<Tag[]>([]);
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
null
);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null number | null
>(null); >(null);
@@ -80,27 +78,6 @@ export default function UptimeAlertSection({
enabled: isPaid enabled: isPaid
}); });
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
const allUsers = useMemo(
() =>
orgUsers.map((u) => ({
id: String(u.id),
text: getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
})
})),
[orgUsers]
);
const allRoles = useMemo(
() => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })),
[orgRoles]
);
const hasRules = (alertRules?.length ?? 0) > 0; const hasRules = (alertRules?.length ?? 0) > 0;
async function handleSubmit() { async function handleSubmit() {
@@ -201,7 +178,9 @@ export default function UptimeAlertSection({
{t("uptimeSectionDescription", { days })} {t("uptimeSectionDescription", { days })}
</SettingsSectionDescription> </SettingsSectionDescription>
</div> </div>
{alertButton} {!env.flags.disableEnterpriseFeatures
? alertButton
: null}
</div> </div>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
@@ -227,10 +206,16 @@ export default function UptimeAlertSection({
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-4"> <div className="space-y-4">
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} /> <PaidFeaturesAlert
tiers={tierMatrix.alertingRules}
/>
<fieldset <fieldset
disabled={!isPaid} disabled={!isPaid}
className={!isPaid ? "opacity-50 pointer-events-none" : ""} className={
!isPaid
? "opacity-50 pointer-events-none"
: ""
}
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -240,65 +225,53 @@ export default function UptimeAlertSection({
<Input <Input
id="alert-name" id="alert-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) =>
placeholder={t("uptimeAlertNamePlaceholder")} setName(e.target.value)
}
placeholder={t(
"uptimeAlertNamePlaceholder"
)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("alertingNotifyUsers")}</Label> <Label>
<TagInput {t("alertingNotifyUsers")}
activeTagIndex={activeUserTagIndex} </Label>
setActiveTagIndex={setActiveUserTagIndex} <UsersSelector
placeholder={t("alertingSelectUsers")} selectedUsers={userTags}
size="sm" orgId={orgId}
tags={userTags} onSelectUsers={setUserTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
setUserTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allUsers}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("alertingNotifyRoles")}</Label> <Label>
<TagInput {t("alertingNotifyRoles")}
activeTagIndex={activeRoleTagIndex} </Label>
setActiveTagIndex={setActiveRoleTagIndex} <RolesSelector
placeholder={t("alertingSelectRoles")} selectedRoles={roleTags}
size="sm" restrictAdminRole
tags={roleTags} orgId={orgId}
setTags={(newTags) => { onSelectRoles={setRoleTags}
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
setRoleTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allRoles}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("uptimeAdditionalEmails")}</Label> <Label>
{t("uptimeAdditionalEmails")}
</Label>
<TagInput <TagInput
activeTagIndex={activeEmailTagIndex} activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={setActiveEmailTagIndex} setActiveTagIndex={
placeholder={t("alertingEmailPlaceholder")} setActiveEmailTagIndex
}
placeholder={t(
"alertingEmailPlaceholder"
)}
size="sm" size="sm"
tags={emailTags} tags={emailTags}
setTags={(newTags) => { setTags={(newTags) => {
const next = const next =
typeof newTags === "function" typeof newTags ===
"function"
? newTags(emailTags) ? newTags(emailTags)
: newTags; : newTags;
setEmailTags(next as Tag[]); setEmailTags(next as Tag[]);
@@ -306,7 +279,9 @@ export default function UptimeAlertSection({
allowDuplicates={false} allowDuplicates={false}
sortTags sortTags
validateTag={(tag) => validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
tag
)
} }
delimiterList={[",", "Enter"]} delimiterList={[",", "Enter"]}
/> />

View File

@@ -17,6 +17,7 @@ import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { validateOidcUrlCallbackProxy } from "@app/actions/server"; import { validateOidcUrlCallbackProxy } from "@app/actions/server";
import { build } from "@server/build";
type ValidateOidcTokenParams = { type ValidateOidcTokenParams = {
orgId: string; orgId: string;
@@ -96,7 +97,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
stateCookie: props.stateCookie stateCookie: props.stateCookie
}); });
if (isLicenseViolation()) { if (build === "enterprise" && isLicenseViolation()) {
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
} }

View File

@@ -1,5 +1,8 @@
"use client"; "use client";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { import {
@@ -21,11 +24,13 @@ import {
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { Textarea } from "@app/components/ui/textarea"; import { Textarea } from "@app/components/ui/textarea";
import { Label } from "@app/components/ui/label";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -33,24 +38,21 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { import {
type AlertRuleFormAction, type AlertRuleFormAction,
type AlertRuleFormValues type AlertRuleFormValues
} from "@app/lib/alertRuleForm"; } from "@app/lib/alertRuleForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { Control, UseFormReturn } from "react-hook-form"; import type { Control, UseFormReturn } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { RolesSelector } from "../roles-selector";
import { UsersSelector } from "../users-selector";
export function AddActionPanel({ export function AddActionPanel({
onAdd onAdd
@@ -498,12 +500,6 @@ function NotifyActionFields({
const t = useTranslations(); const t = useTranslations();
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null); const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery( const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
orgQueries.users({ orgId }) orgQueries.users({ orgId })
@@ -574,14 +570,6 @@ function NotifyActionFields({
hasResolvedTagsRef.current = true; hasResolvedTagsRef.current = true;
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); }, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
const userTags = (useWatch({
control,
name: `actions.${index}.userTags`
}) ?? []) as Tag[];
const roleTags = (useWatch({
control,
name: `actions.${index}.roleTags`
}) ?? []) as Tag[];
const emailTags = (useWatch({ const emailTags = (useWatch({
control, control,
name: `actions.${index}.emailTags` name: `actions.${index}.emailTags`
@@ -596,29 +584,16 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyUsers")}</FormLabel> <FormLabel>{t("alertingNotifyUsers")}</FormLabel>
<FormControl> <FormControl>
<TagInput <UsersSelector
{...field} selectedUsers={field.value ?? []}
activeTagIndex={activeUsersTagIndex} orgId={orgId}
setActiveTagIndex={setActiveUsersTagIndex} onSelectUsers={(newUsers) => {
placeholder={t("alertingSelectUsers")}
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
form.setValue( form.setValue(
`actions.${index}.userTags`, `actions.${index}.userTags`,
next as Tag[], newUsers as [Tag, ...Tag[]],
{ shouldDirty: true } { shouldDirty: true }
); );
}} }}
enableAutocomplete={true}
autocompleteOptions={allUsers}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -632,29 +607,17 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyRoles")}</FormLabel> <FormLabel>{t("alertingNotifyRoles")}</FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} selectedRoles={field.value ?? []}
activeTagIndex={activeRolesTagIndex} restrictAdminRole
setActiveTagIndex={setActiveRolesTagIndex} orgId={orgId}
placeholder={t("alertingSelectRoles")} onSelectRoles={(newUsers) => {
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
form.setValue( form.setValue(
`actions.${index}.roleTags`, `actions.${index}.roleTags`,
next as Tag[], newUsers as [Tag, ...Tag[]],
{ shouldDirty: true } { shouldDirty: true }
); );
}} }}
enableAutocomplete={true}
autocompleteOptions={allRoles}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { MultiSelectTags } from "./multi-select-tags"; import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedMachine = Pick< export type SelectedMachine = Pick<
ListClientsResponse["clients"][number], ListClientsResponse["clients"][number],
@@ -28,11 +28,13 @@ export function MachinesSelector({
const [debouncedValue] = useDebounce(machineSearchQuery, 150); const [debouncedValue] = useDebounce(machineSearchQuery, 150);
const perPage = 7;
const { data: machines = [] } = useQuery( const { data: machines = [] } = useQuery(
orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue }) orgQueries.machineClients({ orgId, perPage, query: debouncedValue })
); );
// always include the selected machines in the list of machines shown (if the user isn't searching) // always include the selected machines in the list (if the user isn't searching)
const machinesShown = useMemo(() => { const machinesShown = useMemo(() => {
const allMachines: Array<SelectedMachine> = [...machines]; const allMachines: Array<SelectedMachine> = [...machines];
if (debouncedValue.trim().length === 0) { if (debouncedValue.trim().length === 0) {
@@ -44,75 +46,32 @@ export function MachinesSelector({
} }
} }
} }
return allMachines; return allMachines;
}, [machines, selectedMachines, debouncedValue]); }, [machines, selectedMachines, debouncedValue]);
// const selectedMachinesIds = new Set(
// selectedMachines.map((m) => m.clientId)
// );
return ( return (
<MultiSelectTags <MultiSelectTagInput
buttonText={t("accessClientSelect")}
searchPlaceholder={t("search")}
emptyPlaceholder={t("machineNotFound")} emptyPlaceholder={t("machineNotFound")}
searchPlaceholder={t("machineSearch")}
value={selectedMachines.map((m) => ({
...m,
text: m.name,
id: m.clientId.toString()
}))}
onChange={(values) => {
onSelectMachines(values);
}}
options={machinesShown.map((m) => ({
...m,
id: m.clientId.toString(),
text: m.name
}))}
onSearch={setMachineSearchQuery}
searchQuery={machineSearchQuery} searchQuery={machineSearchQuery}
onSearch={setMachineSearchQuery}
options={machinesShown.map((mc) => ({
id: mc.clientId.toString(),
text: mc.name
}))}
value={selectedMachines.map((mc) => ({
id: mc.clientId.toString(),
text: mc.name
}))}
onChange={(newValues) => {
onSelectMachines(
newValues.map((v) => ({
clientId: Number(v.id),
name: v.text
}))
);
}}
/> />
// <Command shouldFilter={false}>
// <CommandInput
// placeholder={t("machineSearch")}
// value={machineSearchQuery}
// onValueChange={setMachineSearchQuery}
// />
// <CommandList>
// <CommandEmpty>{t("machineNotFound")}</CommandEmpty>
// <CommandGroup>
// {machinesShown.map((m) => (
// <CommandItem
// value={`${m.name}:${m.clientId}`}
// key={m.clientId}
// onSelect={() => {
// let newMachineClients = [];
// if (selectedMachinesIds.has(m.clientId)) {
// newMachineClients = selectedMachines.filter(
// (mc) => mc.clientId !== m.clientId
// );
// } else {
// newMachineClients = [
// ...selectedMachines,
// m
// ];
// }
// onSelectMachines(newMachineClients);
// }}
// >
// <CheckIcon
// className={cn(
// "mr-2 h-4 w-4",
// selectedMachinesIds.has(m.clientId)
// ? "opacity-100"
// : "opacity-0"
// )}
// />
// {`${m.name}`}
// </CommandItem>
// ))}
// </CommandGroup>
// </CommandList>
// </Command>
); );
} }

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