Compare commits

...

187 Commits

Author SHA1 Message Date
Owen
4e07e9c52c Working on new target type 2026-05-13 11:56:23 -07:00
Owen
743621eb25 Add browserGatewayTarget table 2026-05-12 21:48:59 -07:00
Owen
943923ff4b Add basic vnc test 2026-05-12 21:12:01 -07:00
Owen
3f17f1a468 Support rdp 2026-05-12 21:12:01 -07:00
Owen
436996a43d Add first iteration of ssh proxy 2026-05-12 21:12:01 -07:00
Owen
d42b6076d2 Comment out some fields 2026-05-12 21:12:01 -07:00
Owen
89cc99f915 Initial rdp working 2026-05-12 21:12:00 -07:00
Owen
efb1d69ac9 Add migration 2026-05-12 20:59:58 -07:00
Owen Schwartz
107986d848 Merge pull request #3059 from fosrl/crowdin_dev
New Crowdin updates
2026-05-12 20:45:53 -07:00
Owen Schwartz
b6c8fbe43b New translations en-us.json (Spanish)
[ci skip]
2026-05-12 20:41:30 -07:00
Owen Schwartz
4208a9f372 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-12 20:41:28 -07:00
Owen Schwartz
3c82a228fb New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-12 20:41:26 -07:00
Owen Schwartz
a4aa29e48a New translations en-us.json (Turkish)
[ci skip]
2026-05-12 20:41:25 -07:00
Owen Schwartz
0f82ba6627 New translations en-us.json (Russian)
[ci skip]
2026-05-12 20:41:23 -07:00
Owen Schwartz
1df5d9fac8 New translations en-us.json (Portuguese)
[ci skip]
2026-05-12 20:41:21 -07:00
Owen Schwartz
5189583d73 New translations en-us.json (Polish)
[ci skip]
2026-05-12 20:41:20 -07:00
Owen Schwartz
b794d2aa40 New translations en-us.json (Dutch)
[ci skip]
2026-05-12 20:41:18 -07:00
Owen Schwartz
c69059b227 New translations en-us.json (Korean)
[ci skip]
2026-05-12 20:41:17 -07:00
Owen Schwartz
b27b62d4c8 New translations en-us.json (Italian)
[ci skip]
2026-05-12 20:41:15 -07:00
Owen Schwartz
ee8290d68c New translations en-us.json (German)
[ci skip]
2026-05-12 20:41:13 -07:00
Owen Schwartz
82e8e79b16 New translations en-us.json (Czech)
[ci skip]
2026-05-12 20:41:12 -07:00
Owen Schwartz
2d428d2fa0 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-12 20:41:10 -07:00
Owen Schwartz
0005c11a0a New translations en-us.json (French)
[ci skip]
2026-05-12 20:41:08 -07:00
Owen
f91d914ec6 Show when a domain is config managed 2026-05-12 20:14:12 -07:00
miloschwartz
b6caeda0a5 improve targets round robin warning 2026-05-11 22:06:43 -07:00
Owen
77d17af15b Add global hide_powered_by and make it backward 2026-05-11 16:18:57 -07:00
Owen
264c6bf4e8 Use the right param for user 2026-05-11 12:06:36 -07:00
Owen
4aa72eb1a3 Confirm delete of share links 2026-05-11 11:49:51 -07:00
Owen
a066a68e1a Pick the most specific domain
Fixes #3047
2026-05-11 11:28:32 -07:00
miloschwartz
9fb677e952 allow editing self and owner user roles 2026-05-08 17:48:43 -07:00
Owen Schwartz
88d8414eb8 Merge pull request #3016 from fosrl/crowdin_dev
New Crowdin updates
2026-05-08 17:17:30 -07:00
Owen Schwartz
5f3fafb1b0 New translations en-us.json (Spanish)
[ci skip]
2026-05-08 17:16:31 -07:00
Owen Schwartz
de1338a8cd New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-08 17:16:29 -07:00
Owen Schwartz
0800aa2a61 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-08 17:16:28 -07:00
Owen Schwartz
4959d66ac1 New translations en-us.json (Turkish)
[ci skip]
2026-05-08 17:16:26 -07:00
Owen Schwartz
9320df8be6 New translations en-us.json (Russian)
[ci skip]
2026-05-08 17:16:24 -07:00
Owen Schwartz
13ec6b6620 New translations en-us.json (Portuguese)
[ci skip]
2026-05-08 17:16:23 -07:00
Owen Schwartz
2ca3ef019c New translations en-us.json (Polish)
[ci skip]
2026-05-08 17:16:21 -07:00
Owen Schwartz
724e41a54f New translations en-us.json (Dutch)
[ci skip]
2026-05-08 17:16:19 -07:00
Owen Schwartz
ce5e62d216 New translations en-us.json (Korean)
[ci skip]
2026-05-08 17:16:17 -07:00
Owen Schwartz
874dc2b33e New translations en-us.json (Italian)
[ci skip]
2026-05-08 17:16:16 -07:00
Owen Schwartz
3b2622d590 New translations en-us.json (German)
[ci skip]
2026-05-08 17:16:14 -07:00
Owen Schwartz
c81d855741 New translations en-us.json (Czech)
[ci skip]
2026-05-08 17:16:12 -07:00
Owen Schwartz
3bce8d3596 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-08 17:16:10 -07:00
Owen Schwartz
ee2a1e2bc3 New translations en-us.json (French)
[ci skip]
2026-05-08 17:16:09 -07:00
Owen Schwartz
a0f3ee74f9 New translations en-us.json (Spanish)
[ci skip]
2026-05-08 17:14:09 -07:00
Owen Schwartz
82a36fd632 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-08 17:14:07 -07:00
Owen Schwartz
c5084137ab New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-08 17:14:06 -07:00
Owen Schwartz
65ec8da100 New translations en-us.json (Turkish)
[ci skip]
2026-05-08 17:14:04 -07:00
Owen Schwartz
e76e7581a5 New translations en-us.json (Russian)
[ci skip]
2026-05-08 17:14:02 -07:00
Owen Schwartz
a97a4b6ec1 New translations en-us.json (Portuguese)
[ci skip]
2026-05-08 17:14:00 -07:00
Owen Schwartz
e38bbde348 New translations en-us.json (Polish)
[ci skip]
2026-05-08 17:13:58 -07:00
Owen Schwartz
026260ddfb New translations en-us.json (Dutch)
[ci skip]
2026-05-08 17:13:57 -07:00
Owen Schwartz
97be5eb7d5 New translations en-us.json (Korean)
[ci skip]
2026-05-08 17:13:55 -07:00
Owen Schwartz
d7b96ba3f5 New translations en-us.json (Italian)
[ci skip]
2026-05-08 17:13:53 -07:00
Owen Schwartz
b42672530f New translations en-us.json (German)
[ci skip]
2026-05-08 17:13:51 -07:00
Owen Schwartz
b6b2dbd8ab New translations en-us.json (Czech)
[ci skip]
2026-05-08 17:13:50 -07:00
Owen Schwartz
975f3a01f5 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-08 17:13:48 -07:00
Owen Schwartz
4de2dfff85 New translations en-us.json (French)
[ci skip]
2026-05-08 17:13:46 -07:00
Owen
27d230647f Merge branch 's3' into dev 2026-05-08 17:05:39 -07:00
Owen
114486608e Add client endpoint to network log 2026-05-08 17:04:58 -07:00
Owen
10fa9274d0 Add streaming errors for debug 2026-05-08 16:27:40 -07:00
Owen
cbdc74768f Implement s3 streaming destination 2026-05-07 21:09:21 -07:00
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
451f3d24a8 New translations en-us.json (French)
[ci skip]
2026-05-06 17:13:33 -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
Fred KISSIE
1ca1059673 ♻️ 10 users/roles per page 2026-05-04 20:59:46 +02:00
Fred KISSIE
49d22498fc ♻️ only select one role in CE and if user is non paying 2026-05-04 20:47:00 +02: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
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
129 changed files with 6952 additions and 2280 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

@@ -0,0 +1,60 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
/**
* Disable 2FA for a user by email address.
*/
type DisableUser2faArgs = {
email: string;
};
export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = {
command: "disable-user-2fa",
describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)",
builder: (yargs) => {
return yargs.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
});
},
handler: async (argv: { email: string }) => {
try {
const { email } = argv;
console.log(`Looking for user with email: ${email}`);
// Find the user by email
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
console.error(`User with email '${email}' not found`);
process.exit(1);
}
if (!user.twoFactorEnabled) {
console.log(`2FA is already disabled for user '${email}'.`);
process.exit(0);
}
// Update user: disable 2FA and clear secret
await db.update(users)
.set({
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorSetupRequested: false
})
.where(eq(users.userId, user.userId));
console.log(`2FA disabled for user '${email}'.`);
process.exit(0);
} catch (error) {
console.error("Error disabling 2FA:", error);
process.exit(1);
}
}
};

View File

@@ -9,6 +9,8 @@ import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
import { disableUser2fa } from "./commands/disableUser2fa";
yargs(hideBin(process.argv))
.scriptName("pangctl")
@@ -19,5 +21,7 @@ yargs(hideBin(process.argv))
.command(clearLicenseKeys)
.command(deleteClient)
.command(generateOrgCaKeys)
.command(clearCertificates)
.command(disableUser2fa)
.demandCommand()
.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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Възникна грешка при изтриване на връзката",
"shareDeleted": "Връзката беше изтрита",
"shareDeletedDescription": "Връзката беше премахната",
"shareDelete": "Изтрийте споделената връзка",
"shareDeleteConfirm": "Потвърдете изтриването на споделената връзка",
"shareQuestionRemove": "Сигурни ли сте, че искате да изтриете тази споделена връзка?",
"shareMessageRemove": "След изтриване връзката вече няма да работи и всеки, който я използва, ще загуби достъп до ресурса.",
"shareTokenDescription": "Достъпният токен може да бъде предаван по два начина: като параметър или в хедърите на заявките. Те трябва да бъдат предавани от клиента при всяка заявка за удостоверен достъп.",
"accessToken": "Достъп Токен",
"usageExamples": "Примери за използване",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "След като бъде премахнат, този потребител няма да има достъп до организацията. Винаги можете да го поканите отново по-късно, но той ще трябва да приеме отново поканата.",
"userRemoveOrgConfirm": "Потвърдете премахването на потребителя",
"userRemoveOrg": "Премахване на потребителя от организацията",
"userQuestionOrgRemoveSelf": "Сигурни ли сте, че искате да премахнете себе си от тази организация?",
"userMessageOrgRemoveSelf": "Ще загубите достъп незабавно. Администратор може да ви покани отново по-късно, но ще трябва да приемете нова покана.",
"userRemoveOrgConfirmSelf": "Потвърдете премахването на себе си",
"userRemoveOrgSelf": "Премахнете себе си от организацията",
"userRemoveOrgSelfWarning": "Ще загубите достъп до тази организация незабавно.",
"userRemoveOrgConfirmPhraseSelf": "ПРЕМАХНЕТЕ МЕ ОТ ОРГАНИЗАЦИЯТА",
"users": "Потребители",
"accessRoleMember": "Член",
"accessRoleOwner": "Собственик",
@@ -531,6 +541,11 @@
"emailInvalid": "Невалиден имейл адрес",
"inviteValidityDuration": "Моля, изберете продължителност",
"accessRoleSelectPlease": "Моля, изберете роля",
"removeOwnAdminRoleConfirmTitle": "Премахване на административния ви достъп?",
"removeOwnAdminRoleConfirmDescription": "След записване няма да имате повече администраторски права в тази организация. Друг администратор може да възстанови достъпа, ако е необходимо.",
"removeOwnAdminRoleConfirmButton": "Премахнете административния ми достъп",
"removeOwnAdminRoleConfirmPhrase": "ПРЕМАХНЕТЕ АДМИНИСТРАТИВНИЯ МИ ДОСТЪП",
"ownerMustRetainAdminRole": "Собственикът на организацията трябва да запази поне една администраторска роля.",
"usernameRequired": "Необходимо е потребителско име",
"idpSelectPlease": "Моля, изберете доставчик на идентичност",
"idpGenericOidc": "Основен OAuth2/OIDC доставчик.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Добавянето на повече от една цел ще активира натоварването на баланса.",
"targetsSubmit": "Запазване на целите",
"addTarget": "Добавете цел",
"proxyMultiSiteRoundRobinNodeHelp": "Роунд Робин маршрутизирането няма да работи между сайтове, които не са свързани към един и същ възел, но автоматичното превключване ще работи.",
"targetErrorInvalidIp": "Невалиден IP адрес",
"targetErrorInvalidIpDescription": "Моля, въведете валиден IP адрес или име на хост",
"targetErrorInvalidPort": "Невалиден порт",
@@ -2652,6 +2668,8 @@
"validPassword": "Валидна парола",
"validEmail": "Валиден имейл",
"validSSO": "Валидно SSO",
"view": "Преглед",
"configManaged": "Управлявана конфигурация",
"connectedClient": "Свързан клиент",
"resourceBlocked": "Блокирани ресурси",
"droppedByRule": "Прекратено от правило",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "Няма валидни методи за удостоверение",
"ip": "IP",
"reason": "Причина",
"requestLogs": "Заявка за логове",
"requestLogs": "Логове за HTTP заявки",
"requestAnalytics": "Анализи На Заявки",
"host": "Хост",
"location": "Местоположение",
"actionLogs": "Дневници на действията",
"sidebarLogsRequest": "Заявка за логове",
"sidebarLogsRequest": "Логове за HTTP заявки",
"sidebarLogsAccess": "Достъп до логове",
"sidebarLogsAction": "Дневници на действията",
"logRetention": "Задържане на логове",
"logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте",
"requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация",
"requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация",
"logRetentionRequestLabel": "Задържане на логове на заявки",
"logRetentionRequestLabel": "Задържане на логове за HTTP заявки",
"logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките",
"logRetentionAccessLabel": "Задържане на логове за достъп",
"logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Пресочвайте събития директно към вашият акаунт в Datadog. Очаквайте скоро.",
"streamingTypePickerDescription": "Изберете вид на дестинацията, за да започнете.",
"streamingFailedToLoad": "Неуспешно зареждане на дестинации",
"streamingLastSyncError": "Възникна грешка при последната синхронизация",
"streamingUnexpectedError": "Възникна неочаквана грешка.",
"streamingFailedToUpdate": "Неуспешно актуализиране на дестинация",
"streamingDeletedSuccess": "Дестинацията беше изтрита успешно",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Редактиране на дестинацията",
"S3DestAddTitle": "Добавете S3 дестинация",
"S3DestEditDescription": "Актуализирайте конфигурацията за тази S3 дестинация за предаване на събития.",
"S3DestAddDescription": "Конфигурирайте нов крайна точка на S3, за да получавате събития на вашата организация.",
"S3DestAddDescription": "Конфигурирайте ново хранилище Amazon S3 (или съвместимо с S3), за да получавате събития на вашата организация.",
"s3DestTabSettings": "Настройки",
"s3DestTabFormat": "Формат",
"s3DestNameLabel": "Име",
"s3DestNamePlaceholder": "Моята S3 дестинация",
"s3DestAccessKeyIdLabel": "Идентификатор на достъп за AWS Key ID",
"s3DestSecretAccessKeyLabel": "Тайният ключ за достъп на AWS",
"s3DestSecretAccessKeyPlaceholder": "Вашият таен ключ за достъп за AWS",
"s3DestRegionLabel": "AWS Регион",
"s3DestBucketLabel": "Име на хранилище",
"s3DestPrefixLabel": "Префикс на ключ (по избор)",
"s3DestPrefixDescription": "По избор пътеводен префикс, добавен към всеки обектен ключ. Обектите се съхраняват в {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Потребителски крайна точка (по избор)",
"s3DestEndpointDescription": "Заместете крайната точка на S3 за съвместимо с S3 хранилище като MinIO или Cloudflare R2. Оставете празно за стандартното AWS S3.",
"s3DestGzipLabel": "Gzip компресия",
"s3DestGzipDescription": "Компресирайте всеки качен обект с gzip. Намалява разходите за съхранение и размера на качването.",
"s3DestFormatTitle": "Формат на файл",
"s3DestFormatDescription": "Как събитията са сериализирани вътре във всеки качен обект.",
"s3DestFormatJsonArrayDescription": "Всеки обект е JSON масив от записи на събития. Съвместим с повечето аналитични инструменти.",
"s3DestFormatNdjsonDescription": "Всеки обект съдържа един JSON запис на ред (форматиран JSON с нов ред). Съвместим с Athena, BigQuery и Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Всеки обект е RFC-4180 CSV файл с ред заглавие. Имената на колоните са извлечени от полетата на данните за събитията.",
"s3DestSaveChanges": "Запази промените",
"s3DestCreateDestination": "Създаване на дестинация",
"s3DestUpdatedSuccess": "Дестинацията е актуализирана успешно",
"s3DestCreatedSuccess": "Дестинацията е създадена успешно",
"s3DestUpdateFailed": "Неуспешно актуализиране на дестинацията",
"s3DestCreateFailed": "Неуспешно създаване на дестинация",
"datadogDestEditTitle": "Редактиране на дестинация",
"datadogDestAddTitle": "Добавяне на Datadog дестинация",
"datadogDestEditDescription": "Актуализирайте конфигурацията за тази Datadog дестинация за предаване на събития.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.",
"httpDestConnectionLogsTitle": "Логове на връзката",
"httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.",
"httpDestRequestLogsTitle": "Заявки за логове",
"httpDestRequestLogsTitle": "Логове за HTTP заявки",
"httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.",
"httpDestSaveChanges": "Запази промените",
"httpDestCreateDestination": "Създаване на дестинация",
@@ -3174,7 +3219,7 @@
"publicIpEndpoint": "Крайна точка",
"lastTriggeredAt": "Последен тригер",
"reject": "Отхвърляне",
"uptimeDaysAgo": "{count} days ago",
"uptimeDaysAgo": "преди {count} дни",
"uptimeToday": "Днес",
"uptimeNoDataAvailable": "Няма налични данни",
"uptimeSuffix": "време без прекъсване",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.",
"domainPickerWildcardCertWarningLink": "Научете повече",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Došlo k chybě při odstraňování odkazu",
"shareDeleted": "Odkaz odstraněn",
"shareDeletedDescription": "Odkaz byl odstraněn",
"shareDelete": "Smazat odkaz ke sdílení",
"shareDeleteConfirm": "Potvrdit smazání odkazu ke sdílení",
"shareQuestionRemove": "Jste si jisti, že chcete smazat tento odkaz ke sdílení?",
"shareMessageRemove": "Jakmile bude smazán, odkaz přestane fungovat a všichni, kdo jej používají, ztratí přístup k prostředku.",
"shareTokenDescription": "Přístupový token může být předán dvěma způsoby: jako parametr dotazu nebo v záhlaví požadavku. Tyto údaje musí být předány klientovi na každé žádosti o ověřený přístup.",
"accessToken": "Přístupový token",
"usageExamples": "Příklady použití",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Po odstranění tohoto uživatele již nebude mít přístup k organizaci. Vždy je můžete znovu pozvat později, ale budou muset pozvání znovu přijmout.",
"userRemoveOrgConfirm": "Potvrdit odebrání uživatele",
"userRemoveOrg": "Odebrat uživatele z organizace",
"userQuestionOrgRemoveSelf": "Jste si jisti, že se chcete odstranit z této organizace?",
"userMessageOrgRemoveSelf": "Okamžitě ztratíte přístup. Administrátor vás může později znovu pozvat, ale budete muset přijmout nové pozvání.",
"userRemoveOrgConfirmSelf": "Potvrdit odstranění sebe",
"userRemoveOrgSelf": "Odstranit se z organizace",
"userRemoveOrgSelfWarning": "Okamžitě ztratíte přístup k této organizaci.",
"userRemoveOrgConfirmPhraseSelf": "ODSTRANIT SE Z ORGANIZACE",
"users": "Uživatelé",
"accessRoleMember": "Člen",
"accessRoleOwner": "Vlastník",
@@ -531,6 +541,11 @@
"emailInvalid": "Neplatná e-mailová adresa",
"inviteValidityDuration": "Zvolte prosím dobu trvání",
"accessRoleSelectPlease": "Vyberte prosím roli",
"removeOwnAdminRoleConfirmTitle": "Odebrat přístup správce?",
"removeOwnAdminRoleConfirmDescription": "Po uložení již nebudete mít oprávnění správce v této organizaci. Další administrátor vám může přístup obnovit, pokud bude potřeba.",
"removeOwnAdminRoleConfirmButton": "Odebrat mé administrátorské oprávnění",
"removeOwnAdminRoleConfirmPhrase": "ODEBRAT MÉ ADMINISTRÁTORSKÉ OPRÁVNĚNÍ",
"ownerMustRetainAdminRole": "Vlastník organizace musí zachovat alespoň jednu roli správce.",
"usernameRequired": "Uživatelské jméno je povinné",
"idpSelectPlease": "Vyberte poskytovatele identity",
"idpGenericOidc": "Generic OAuth2/OIDC provider.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Přidáním více než jednoho cíle se umožní vyvážení zatížení.",
"targetsSubmit": "Uložit cíle",
"addTarget": "Add Target",
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing nebude fungovat mezi lokalitami, které nejsou připojeny ke stejnému uzlu, ale failover bude fungovat.",
"targetErrorInvalidIp": "Neplatná IP adresa",
"targetErrorInvalidIpDescription": "Zadejte prosím platnou IP adresu nebo název hostitele",
"targetErrorInvalidPort": "Neplatný port",
@@ -2652,6 +2668,8 @@
"validPassword": "Platné heslo",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Zobrazit",
"configManaged": "Správa konfigurace",
"connectedClient": "Připojený klient",
"resourceBlocked": "Zablokované zdroje",
"droppedByRule": "Zrušeno pravidlem",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP adresa",
"reason": "Důvod",
"requestLogs": "Záznamy požadavků",
"requestLogs": "Záznamy HTTP požadavků",
"requestAnalytics": "Vyžádat analýzu",
"host": "Hostitel",
"location": "Poloha",
"actionLogs": "Záznamy akcí",
"sidebarLogsRequest": "Záznamy požadavků",
"sidebarLogsRequest": "Záznamy HTTP požadavků",
"sidebarLogsAccess": "Protokoly přístupu",
"sidebarLogsAction": "Záznamy akcí",
"logRetention": "Zaznamenávání záznamu",
"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",
"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ů",
"logRetentionAccessLabel": "Zachování záznamu přístupu",
"logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Přeposlat události přímo do vašeho účtu Datadog účtu. Brzy přijde.",
"streamingTypePickerDescription": "Vyberte cílový typ pro začátek.",
"streamingFailedToLoad": "Nepodařilo se načíst destinace",
"streamingLastSyncError": "Došlo k chybě při poslední synchronizaci",
"streamingUnexpectedError": "Došlo k neočekávané chybě.",
"streamingFailedToUpdate": "Nepodařilo se aktualizovat cíl",
"streamingDeletedSuccess": "Cíl byl úspěšně odstraněn",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Upravit cíl",
"S3DestAddTitle": "Přidat S3 cíl",
"S3DestEditDescription": "Aktualizujte konfiguraci tohoto S3 cíle pro streamování událostí.",
"S3DestAddDescription": "Konfigurujte nový S3 koncový bod pro přijímání událostí vaší organizace.",
"S3DestAddDescription": "Nakonfigurujte nový Amazon S3 (nebo S3-kompatibilní) bucket, aby přijímal události vaší organizace.",
"s3DestTabSettings": "Nastavení",
"s3DestTabFormat": "Formát",
"s3DestNameLabel": "Jméno",
"s3DestNamePlaceholder": "Moje cílové S3",
"s3DestAccessKeyIdLabel": "ID přístupového klíče AWS",
"s3DestSecretAccessKeyLabel": "Tajný přístupový klíč AWS",
"s3DestSecretAccessKeyPlaceholder": "Váš tajný přístupový klíč AWS",
"s3DestRegionLabel": "Oblast AWS",
"s3DestBucketLabel": "Název bucketu",
"s3DestPrefixLabel": "Předpona klíče (volitelné)",
"s3DestPrefixDescription": "Volitelná cesta předpony přidaná ke každému objektovému klíči. Objekty jsou uloženy na {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Vlastní koncový bod (volitelné)",
"s3DestEndpointDescription": "Přepište koncový bod S3 pro S3-kompatibilní úložiště, jako je MinIO nebo Cloudflare R2. Nechte prázdné pro standardní AWS S3.",
"s3DestGzipLabel": "Komprese Gzip",
"s3DestGzipDescription": "Komprimujte každý nahraný objekt pomocí gzip. Snižuje náklady na uložení a velikost nahrávání.",
"s3DestFormatTitle": "Formát souboru",
"s3DestFormatDescription": "Jak jsou události serializovány v každém nahraném objektu.",
"s3DestFormatJsonArrayDescription": "Každý objekt je pole JSON záznamů událostí. Kompatibilní s většinou analytických nástrojů.",
"s3DestFormatNdjsonDescription": "Každý objekt obsahuje jeden JSON záznam na řádku (newline-delimited JSON). Kompatibilní s Athena, BigQuery a Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Každý objekt je soubor CSV podle RFC-4180 s řádkem záhlaví. Názvy sloupců jsou odvozeny z polí dat událostí.",
"s3DestSaveChanges": "Uložit změny",
"s3DestCreateDestination": "Vytvořit destinaci",
"s3DestUpdatedSuccess": "Destinace úspěšně aktualizována",
"s3DestCreatedSuccess": "Destinace úspěšně vytvořena",
"s3DestUpdateFailed": "Aktualizace destinace se nezdařila",
"s3DestCreateFailed": "Vytvoření destinace se nezdařilo",
"datadogDestEditTitle": "Upravit cíl",
"datadogDestAddTitle": "Přidat Datadog cíl",
"datadogDestEditDescription": "Aktualizujte konfiguraci tohoto Datadog cíle pro streamování událostí.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.",
"httpDestConnectionLogsTitle": "Protokoly připojení",
"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.",
"httpDestSaveChanges": "Uložit změny",
"httpDestCreateDestination": "Vytvořit cíl",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.",
"domainPickerWildcardCertWarningLink": "Zjistit více",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Fehler beim Löschen des Links",
"shareDeleted": "Link gelöscht",
"shareDeletedDescription": "Der Link wurde gelöscht",
"shareDelete": "Freigabelink löschen",
"shareDeleteConfirm": "Löschen des Freigabelinks bestätigen",
"shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?",
"shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.",
"shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.",
"accessToken": "Zugangs-Token",
"usageExamples": "Nutzungsbeispiele",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Nach dem Entfernen hat dieser Benutzer keinen Zugriff mehr auf die Organisation. Sie können ihn später jederzeit wieder einladen, aber er muss die Einladung erneut annehmen.",
"userRemoveOrgConfirm": "Entfernen des Benutzers bestätigen",
"userRemoveOrg": "Benutzer aus der Organisation entfernen",
"userQuestionOrgRemoveSelf": "Sind Sie sicher, dass Sie sich aus dieser Organisation entfernen möchten?",
"userMessageOrgRemoveSelf": "Sie verlieren sofort den Zugriff. Ein Administrator kann Sie später erneut einladen, aber Sie müssen eine neue Einladung annehmen.",
"userRemoveOrgConfirmSelf": "Entfernung bestätigen",
"userRemoveOrgSelf": "Sich selbst aus der Organisation entfernen",
"userRemoveOrgSelfWarning": "Sie verlieren sofort den Zugriff auf diese Organisation.",
"userRemoveOrgConfirmPhraseSelf": "ENTFERNUNG MICH SELBST AUS DER ORGANISATION",
"users": "Benutzer",
"accessRoleMember": "Mitglied",
"accessRoleOwner": "Eigentümer",
@@ -531,6 +541,11 @@
"emailInvalid": "Ungültige E-Mail-Adresse",
"inviteValidityDuration": "Bitte wählen Sie eine Dauer",
"accessRoleSelectPlease": "Bitte wählen Sie eine Rolle",
"removeOwnAdminRoleConfirmTitle": "Möchten Sie Ihren Administratorzugriff entfernen?",
"removeOwnAdminRoleConfirmDescription": "Nach dem Speichern haben Sie keine Administratorrechte mehr in dieser Organisation. Ein anderer Administrator kann den Zugriff bei Bedarf wiederherstellen.",
"removeOwnAdminRoleConfirmButton": "Meinen Administratorzugriff entfernen",
"removeOwnAdminRoleConfirmPhrase": "NIMM MEINEN ADMIN-ZUGRIFF WEG",
"ownerMustRetainAdminRole": "Der Organisationsinhaber muss mindestens eine Administratorrolle behalten.",
"usernameRequired": "Benutzername ist erforderlich",
"idpSelectPlease": "Bitte wählen Sie einen Identitätsanbieter",
"idpGenericOidc": "Generischer OAuth2/OIDC-Anbieter.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Das Hinzufügen von mehr als einem Ziel aktiviert den Lastausgleich.",
"targetsSubmit": "Ziele speichern",
"addTarget": "Ziel hinzufügen",
"proxyMultiSiteRoundRobinNodeHelp": "Round-Robin-Routing funktioniert nicht zwischen Standorten, die nicht mit demselben Knoten verbunden sind, aber Failover funktioniert.",
"targetErrorInvalidIp": "Ungültige IP-Adresse",
"targetErrorInvalidIpDescription": "Bitte geben Sie eine gültige IP-Adresse oder einen Hostnamen ein",
"targetErrorInvalidPort": "Ungültiger Port",
@@ -2652,6 +2668,8 @@
"validPassword": "Gültiges Passwort",
"validEmail": "Gültige E-Mail-Adresse",
"validSSO": "Gültige SSO-Anmeldung",
"view": "Ansehen",
"configManaged": "Konfiguration verwaltet",
"connectedClient": "Verbundenes Gerät",
"resourceBlocked": "Ressource blockiert",
"droppedByRule": "Abgelegt durch Regel",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar",
"ip": "IP",
"reason": "Grund",
"requestLogs": "Logs anfordern",
"requestLogs": "HTTP Anforderungsprotokolle",
"requestAnalytics": "Anfrage-Analyse anzeigen",
"host": "Host",
"location": "Standort",
"actionLogs": "Aktionsprotokolle",
"sidebarLogsRequest": "Logs anfordern",
"sidebarLogsRequest": "HTTP Anforderungsprotokolle",
"sidebarLogsAccess": "Zugriffsprotokolle",
"sidebarLogsAction": "Aktionsprotokolle",
"logRetention": "Log-Speicherung",
"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",
"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",
"logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung",
"logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Events direkt an Ihr Datadog Konto weiterleiten. Kommen Sie bald.",
"streamingTypePickerDescription": "Wählen Sie einen Zieltyp aus, um loszulegen.",
"streamingFailedToLoad": "Fehler beim Laden der Ziele",
"streamingLastSyncError": "Beim letzten Synchronisieren ist ein Fehler aufgetreten.",
"streamingUnexpectedError": "Ein unerwarteter Fehler ist aufgetreten.",
"streamingFailedToUpdate": "Fehler beim Aktualisieren des Ziels",
"streamingDeletedSuccess": "Ziel erfolgreich gelöscht",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Ziel bearbeiten",
"S3DestAddTitle": "S3-Ziel hinzufügen",
"S3DestEditDescription": "Konfiguration für dieses S3-Ereignis-Streamingziel aktualisieren.",
"S3DestAddDescription": "Neuen S3-Endpunkt konfigurieren, um die Ereignisse Ihrer Organisation zu erhalten.",
"S3DestAddDescription": "Konfigurieren Sie einen neuen Amazon S3 (oder S3-kompatiblen) Bucket, um die Ereignisse Ihrer Organisation zu empfangen.",
"s3DestTabSettings": "Einstellungen",
"s3DestTabFormat": "Format",
"s3DestNameLabel": "Name",
"s3DestNamePlaceholder": "Mein S3-Ziel",
"s3DestAccessKeyIdLabel": "AWS-Zugriffsschlüssel-ID",
"s3DestSecretAccessKeyLabel": "AWS-Geheimzugriffsschlüssel",
"s3DestSecretAccessKeyPlaceholder": "Ihr AWS-Geheimzugriffsschlüssel",
"s3DestRegionLabel": "AWS-Region",
"s3DestBucketLabel": "Bucket-Name",
"s3DestPrefixLabel": "Schlüssel-Präfix (optional)",
"s3DestPrefixDescription": "Optionales Pfadpräfix, das jedem Objektschlüssel vorangestellt wird. Objekte werden unter {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename} gespeichert.",
"s3DestEndpointLabel": "Benutzerdefinierter Endpunkt (optional)",
"s3DestEndpointDescription": "Überschreiben Sie den S3-Endpunkt für S3-kompatiblen Speicher wie MinIO oder Cloudflare R2. Lassen Sie das Feld leer für standardmäßiges AWS S3.",
"s3DestGzipLabel": "Gzip-Komprimierung",
"s3DestGzipDescription": "Jedes hochgeladene Objekt mit Gzip komprimieren. Reduziert die Speicherkosten und die Upload-Größe.",
"s3DestFormatTitle": "Dateiformat",
"s3DestFormatDescription": "Wie Ereignisse in jedem hochgeladenen Objekt serialisiert werden.",
"s3DestFormatJsonArrayDescription": "Jedes Objekt ist ein JSON-Array von Ereignisdaten. Kompatibel mit den meisten Analysetools.",
"s3DestFormatNdjsonDescription": "Jedes Objekt enthält einen JSON-Datensatz pro Zeile (newline-delimited JSON). Kompatibel mit Athena, BigQuery und Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Jedes Objekt ist eine RFC-4180 CSV-Datei mit einer Kopfzeile. Spaltennamen werden aus den Ereignisdatenfeldern abgeleitet.",
"s3DestSaveChanges": "Änderungen speichern",
"s3DestCreateDestination": "Ziel erstellen",
"s3DestUpdatedSuccess": "Ziel erfolgreich aktualisiert",
"s3DestCreatedSuccess": "Ziel erfolgreich erstellt",
"s3DestUpdateFailed": "Fehler beim Aktualisieren des Ziels",
"s3DestCreateFailed": "Fehler beim Erstellen des Ziels",
"datadogDestEditTitle": "Ziel bearbeiten",
"datadogDestAddTitle": "Datadog-Ziel hinzufügen",
"datadogDestEditDescription": "Konfiguration für dieses Datadog-Ereignis-Streamingziel aktualisieren.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.",
"httpDestConnectionLogsTitle": "Verbindungsprotokolle",
"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.",
"httpDestSaveChanges": "Änderungen speichern",
"httpDestCreateDestination": "Ziel erstellen",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.",
"domainPickerWildcardCertWarningLink": "Mehr erfahren",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted",
"shareDeletedDescription": "The link has been deleted",
"shareDelete": "Delete Share Link",
"shareDeleteConfirm": "Confirm Delete Share Link",
"shareQuestionRemove": "Are you sure you want to delete this share link?",
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
"accessToken": "Access Token",
"usageExamples": "Usage Examples",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.",
"userRemoveOrgConfirm": "Confirm Remove User",
"userRemoveOrg": "Remove User from Organization",
"userQuestionOrgRemoveSelf": "Are you sure you want to remove yourself from this organization?",
"userMessageOrgRemoveSelf": "You will lose access immediately. An administrator can invite you again later, but you will need to accept a new invitation.",
"userRemoveOrgConfirmSelf": "Confirm Remove Myself",
"userRemoveOrgSelf": "Remove yourself from the organization",
"userRemoveOrgSelfWarning": "You will lose access to this organization immediately.",
"userRemoveOrgConfirmPhraseSelf": "REMOVE MYSELF FROM ORG",
"users": "Users",
"accessRoleMember": "Member",
"accessRoleOwner": "Owner",
@@ -531,6 +541,11 @@
"emailInvalid": "Invalid email address",
"inviteValidityDuration": "Please select a duration",
"accessRoleSelectPlease": "Please select a role",
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
"removeOwnAdminRoleConfirmPhrase": "REMOVE MY ADMIN ACCESS",
"ownerMustRetainAdminRole": "The organization owner must keep at least one administrator role.",
"usernameRequired": "Username is required",
"idpSelectPlease": "Please select an identity provider",
"idpGenericOidc": "Generic OAuth2/OIDC provider.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Adding more than one target above will enable load balancing.",
"targetsSubmit": "Save Targets",
"addTarget": "Add Target",
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.",
"targetErrorInvalidIp": "Invalid IP address",
"targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname",
"targetErrorInvalidPort": "Invalid port",
@@ -2652,6 +2668,8 @@
"validPassword": "Valid Password",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "View",
"configManaged": "Config Managed",
"connectedClient": "Connected Client",
"resourceBlocked": "Resource Blocked",
"droppedByRule": "Dropped by Rule",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP",
"reason": "Reason",
"requestLogs": "HTTPS Request Logs",
"requestLogs": "HTTP Request Logs",
"requestAnalytics": "Request Analytics",
"host": "Host",
"location": "Location",
"actionLogs": "Admin Action Logs",
"sidebarLogsRequest": "HTTPS Request Logs",
"sidebarLogsRequest": "HTTP Request Logs",
"sidebarLogsAccess": "Authentication Logs",
"sidebarLogsAction": "Admin Action Logs",
"logRetention": "Log Retention",
"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",
"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",
"logRetentionAccessLabel": "Authentication Log Retention",
"logRetentionAccessDescription": "How long to retain access logs",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Forward events directly to your Datadog account.",
"streamingTypePickerDescription": "Choose a destination type to get started.",
"streamingFailedToLoad": "Failed to load destinations",
"streamingLastSyncError": "An error occurred on the last sync",
"streamingUnexpectedError": "An unexpected error occurred.",
"streamingFailedToUpdate": "Failed to update destination",
"streamingDeletedSuccess": "Destination deleted successfully",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Edit Destination",
"S3DestAddTitle": "Add S3 Destination",
"S3DestEditDescription": "Update the configuration for this S3 event streaming destination.",
"S3DestAddDescription": "Configure a new S3 endpoint to receive your organization's events.",
"S3DestAddDescription": "Configure a new Amazon S3 (or S3-compatible) bucket to receive your organization's events.",
"s3DestTabSettings": "Settings",
"s3DestTabFormat": "Format",
"s3DestNameLabel": "Name",
"s3DestNamePlaceholder": "My S3 destination",
"s3DestAccessKeyIdLabel": "AWS Access Key ID",
"s3DestSecretAccessKeyLabel": "AWS Secret Access Key",
"s3DestSecretAccessKeyPlaceholder": "Your AWS secret access key",
"s3DestRegionLabel": "AWS Region",
"s3DestBucketLabel": "Bucket Name",
"s3DestPrefixLabel": "Key Prefix (optional)",
"s3DestPrefixDescription": "Optional path prefix prepended to every object key. Objects are stored at {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Custom Endpoint (optional)",
"s3DestEndpointDescription": "Override the S3 endpoint for S3-compatible storage such as MinIO or Cloudflare R2. Leave blank for standard AWS S3.",
"s3DestGzipLabel": "Gzip compression",
"s3DestGzipDescription": "Compress each uploaded object with gzip. Reduces storage costs and upload size.",
"s3DestFormatTitle": "File Format",
"s3DestFormatDescription": "How events are serialised inside each uploaded object.",
"s3DestFormatJsonArrayDescription": "Each object is a JSON array of event records. Compatible with most analytics tools.",
"s3DestFormatNdjsonDescription": "Each object contains one JSON record per line (newline-delimited JSON). Compatible with Athena, BigQuery, and Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Each object is an RFC-4180 CSV file with a header row. Column names are derived from the event data fields.",
"s3DestSaveChanges": "Save Changes",
"s3DestCreateDestination": "Create Destination",
"s3DestUpdatedSuccess": "Destination updated successfully",
"s3DestCreatedSuccess": "Destination created successfully",
"s3DestUpdateFailed": "Failed to update destination",
"s3DestCreateFailed": "Failed to create destination",
"datadogDestEditTitle": "Edit Destination",
"datadogDestAddTitle": "Add Datadog Destination",
"datadogDestEditDescription": "Update the configuration for this Datadog event streaming destination.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Administrative actions performed by users within the organization.",
"httpDestConnectionLogsTitle": "Network Logs",
"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.",
"httpDestSaveChanges": "Save Changes",
"httpDestCreateDestination": "Create Destination",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
"domainPickerWildcardCertWarningLink": "Learn more",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Se ha producido un error al eliminar el enlace",
"shareDeleted": "Enlace eliminado",
"shareDeletedDescription": "El enlace ha sido eliminado",
"shareDelete": "Borrar Enlace Compartido",
"shareDeleteConfirm": "Confirmar Borrado del Enlace Compartido",
"shareQuestionRemove": "¿Está seguro de que desea borrar este enlace compartido?",
"shareMessageRemove": "Una vez borrado, el enlace dejará de funcionar y cualquier persona que lo use perderá acceso al recurso.",
"shareTokenDescription": "El token de acceso puede ser pasado de dos maneras: como parámetro de consulta o en las cabeceras de solicitud. Estos deben ser pasados del cliente en cada solicitud de acceso autenticado.",
"accessToken": "Token de acceso",
"usageExamples": "Ejemplos de uso",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Una vez eliminado, este usuario ya no tendrá acceso a la organización. Siempre puede volver a invitarlos más tarde, pero tendrán que aceptar la invitación de nuevo.",
"userRemoveOrgConfirm": "Confirmar eliminar usuario",
"userRemoveOrg": "Eliminar usuario de la organización",
"userQuestionOrgRemoveSelf": "¿Está seguro de que desea eliminarse de esta organización?",
"userMessageOrgRemoveSelf": "Perderá acceso inmediatamente. Un administrador puede invitarlo de nuevo más tarde, pero necesitará aceptar una nueva invitación.",
"userRemoveOrgConfirmSelf": "Confirmar Eliminarme",
"userRemoveOrgSelf": "Eliminarse de la organización",
"userRemoveOrgSelfWarning": "Perderá acceso a esta organización inmediatamente.",
"userRemoveOrgConfirmPhraseSelf": "ELIMINARME DE LA ORGANIZACIÓN",
"users": "Usuarios",
"accessRoleMember": "Miembro",
"accessRoleOwner": "Propietario",
@@ -531,6 +541,11 @@
"emailInvalid": "Dirección de correo inválida",
"inviteValidityDuration": "Por favor, seleccione una duración",
"accessRoleSelectPlease": "Por favor, seleccione un rol",
"removeOwnAdminRoleConfirmTitle": "¿Eliminar su acceso de administrador?",
"removeOwnAdminRoleConfirmDescription": "Ya no tendrá permisos de administrador en esta organización después de guardar. Otro administrador puede restaurar el acceso si es necesario.",
"removeOwnAdminRoleConfirmButton": "Eliminar Mi Acceso de Administrador",
"removeOwnAdminRoleConfirmPhrase": "ELIMINAR MI ACCESO DE ADMINISTRADOR",
"ownerMustRetainAdminRole": "El propietario de la organización debe mantener al menos un rol de administrador.",
"usernameRequired": "Nombre de usuario requerido",
"idpSelectPlease": "Por favor, seleccione un proveedor de identidad",
"idpGenericOidc": "Proveedor OAuth2/OIDC genérico.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Si se añade más de un objetivo anterior se activará el balance de carga.",
"targetsSubmit": "Guardar objetivos",
"addTarget": "Añadir destino",
"proxyMultiSiteRoundRobinNodeHelp": "El enrutamiento de turnos no funcionará entre sitios que no están conectados al mismo nodo, pero el failover funcionará.",
"targetErrorInvalidIp": "Dirección IP inválida",
"targetErrorInvalidIpDescription": "Por favor, introduzca una dirección IP válida o nombre de host",
"targetErrorInvalidPort": "Puerto inválido",
@@ -2652,6 +2668,8 @@
"validPassword": "Contraseña válida",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Ver",
"configManaged": "Configuración Gestionada",
"connectedClient": "Cliente conectado",
"resourceBlocked": "Recurso bloqueado",
"droppedByRule": "Soltado por regla",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP",
"reason": "Razón",
"requestLogs": "Registros de Solicitud",
"requestLogs": "Registros de Solicitud HTTP",
"requestAnalytics": "Analítica de Solicitud",
"host": "Anfitrión",
"location": "Ubicación",
"actionLogs": "Registros de acción",
"sidebarLogsRequest": "Registros de Solicitud",
"sidebarLogsRequest": "Registros de Solicitud HTTP",
"sidebarLogsAccess": "Registros de acceso",
"sidebarLogsAction": "Registros de acción",
"logRetention": "Retención de Log",
"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",
"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",
"logRetentionAccessLabel": "Retención de Log de Acceso",
"logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Reenviar eventos directamente a tu cuenta de Datadog. Próximamente.",
"streamingTypePickerDescription": "Elija un tipo de destino para empezar.",
"streamingFailedToLoad": "Error al cargar destinos",
"streamingLastSyncError": "Ocurrió un error en la última sincronización.",
"streamingUnexpectedError": "Se ha producido un error inesperado.",
"streamingFailedToUpdate": "Error al actualizar destino",
"streamingDeletedSuccess": "Destino eliminado correctamente",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Editar destino",
"S3DestAddTitle": "Añadir destino S3",
"S3DestEditDescription": "Actualice la configuración para este destino de transmisión de eventos S3.",
"S3DestAddDescription": "Configure un nuevo punto final S3 para recibir los eventos de su organización.",
"S3DestAddDescription": "Configura un nuevo bucket de Amazon S3 (o compatible con S3) para recibir los eventos de tu organización.",
"s3DestTabSettings": "Ajustes",
"s3DestTabFormat": "Formato",
"s3DestNameLabel": "Nombre",
"s3DestNamePlaceholder": "Mi destino S3",
"s3DestAccessKeyIdLabel": "ID de clave de acceso de AWS",
"s3DestSecretAccessKeyLabel": "Clave de acceso secreta de AWS",
"s3DestSecretAccessKeyPlaceholder": "Tu clave de acceso secreta de AWS",
"s3DestRegionLabel": "Región de AWS",
"s3DestBucketLabel": "Nombre del bucket",
"s3DestPrefixLabel": "Prefijo clave (opcional)",
"s3DestPrefixDescription": "Prefijo de ruta opcional preanexado a cada clave de objeto. Los objetos se almacenan en {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Punto final personalizado (opcional)",
"s3DestEndpointDescription": "Sobrescribe el punto final de S3 para almacenamiento compatible con S3 como MinIO o Cloudflare R2. Deja en blanco para el estándar AWS S3.",
"s3DestGzipLabel": "Compresión Gzip",
"s3DestGzipDescription": "Comprime cada objeto subido con gzip. Reduce costos de almacenamiento y tamaño de carga.",
"s3DestFormatTitle": "Formato de archivo",
"s3DestFormatDescription": "Cómo se serializan los eventos dentro de cada objeto cargado.",
"s3DestFormatJsonArrayDescription": "Cada objeto es un arreglo JSON de registros de eventos. Compatible con la mayoría de las herramientas de analítica.",
"s3DestFormatNdjsonDescription": "Cada objeto contiene un registro JSON por línea (JSON delimitado por nueva línea). Compatible con Athena, BigQuery y Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Cada objeto es un archivo CSV conforme a RFC-4180 con una fila de encabezado. Los nombres de columna se derivan de los campos de datos del evento.",
"s3DestSaveChanges": "Guardar cambios",
"s3DestCreateDestination": "Crear destino",
"s3DestUpdatedSuccess": "Destino actualizado con éxito",
"s3DestCreatedSuccess": "Destino creado con éxito",
"s3DestUpdateFailed": "No se pudo actualizar el destino",
"s3DestCreateFailed": "No se pudo crear el destino",
"datadogDestEditTitle": "Editar destino",
"datadogDestAddTitle": "Añadir destino Datadog",
"datadogDestEditDescription": "Actualice la configuración para este destino de transmisión de eventos Datadog.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.",
"httpDestConnectionLogsTitle": "Registros de conexión",
"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.",
"httpDestSaveChanges": "Guardar Cambios",
"httpDestCreateDestination": "Crear destino",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.",
"domainPickerWildcardCertWarningLink": "Más información",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Une erreur s'est produite lors de la suppression du lien",
"shareDeleted": "Lien supprimé",
"shareDeletedDescription": "Le lien a été supprimé",
"shareDelete": "Supprimer le lien de partage",
"shareDeleteConfirm": "Confirmer la suppression du lien de partage",
"shareQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce lien de partage ?",
"shareMessageRemove": "Une fois supprimé, le lien ne fonctionnera plus et toute personne l'utilisant perdra l'accès à la ressource.",
"shareTokenDescription": "Le jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.",
"accessToken": "Jeton d'accès",
"usageExamples": "Exemples d'utilisation",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Une fois retiré, cet utilisateur n'aura plus accès à l'organisation. Vous pouvez toujours le réinviter plus tard, mais il devra accepter l'invitation à nouveau.",
"userRemoveOrgConfirm": "Confirmer la suppression de l'utilisateur",
"userRemoveOrg": "Retirer l'utilisateur de l'organisation",
"userQuestionOrgRemoveSelf": "Êtes-vous sûr de vouloir vous retirer de cette organisation ?",
"userMessageOrgRemoveSelf": "Vous perdrez immédiatement l'accès. Un administrateur pourra vous inviter à nouveau plus tard, mais vous devrez accepter une nouvelle invitation.",
"userRemoveOrgConfirmSelf": "Confirmer la suppression de moi-même",
"userRemoveOrgSelf": "Se retirer de l'organisation",
"userRemoveOrgSelfWarning": "Vous perdrez immédiatement l'accès à cette organisation.",
"userRemoveOrgConfirmPhraseSelf": "SUPPRIMER MOI-MÊME DE L'ORG",
"users": "Utilisateurs",
"accessRoleMember": "Membre",
"accessRoleOwner": "Propriétaire",
@@ -531,6 +541,11 @@
"emailInvalid": "Adresse e-mail invalide",
"inviteValidityDuration": "Veuillez sélectionner une durée",
"accessRoleSelectPlease": "Veuillez sélectionner un rôle",
"removeOwnAdminRoleConfirmTitle": "Retirer votre accès administrateur ?",
"removeOwnAdminRoleConfirmDescription": "Vous n'aurez plus de droits d'administrateur dans cette organisation après avoir enregistré. Un autre administrateur pourra restaurer cet accès si nécessaire.",
"removeOwnAdminRoleConfirmButton": "Retirer mon accès administrateur",
"removeOwnAdminRoleConfirmPhrase": "RETIRER MON ACCÈS ADMIN",
"ownerMustRetainAdminRole": "Le propriétaire de l'organisation doit conserver au moins un rôle d'administrateur.",
"usernameRequired": "Le nom d'utilisateur est requis",
"idpSelectPlease": "Veuillez sélectionner un fournisseur d'identité",
"idpGenericOidc": "Fournisseur OAuth2/OIDC générique.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "L'ajout de plus d'une cible ci-dessus activera l'équilibrage de charge.",
"targetsSubmit": "Enregistrer les cibles",
"addTarget": "Ajouter une cible",
"proxyMultiSiteRoundRobinNodeHelp": "Le routage en tourniquet n'opérera pas entre des sites qui ne sont pas connectés au même nœud, mais le basculement fonctionnera.",
"targetErrorInvalidIp": "Adresse IP invalide",
"targetErrorInvalidIpDescription": "Veuillez entrer une adresse IP ou un nom d'hôte valide",
"targetErrorInvalidPort": "Port invalide",
@@ -2652,6 +2668,8 @@
"validPassword": "Mot de passe valide",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Afficher",
"configManaged": "Configuration gérée",
"connectedClient": "Client connecté",
"resourceBlocked": "Ressource bloquée",
"droppedByRule": "Abandonné par la règle",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP",
"reason": "Raison",
"requestLogs": "Journal des requêtes",
"requestLogs": "Journal des Requêtes HTTP",
"requestAnalytics": "Demander des analyses",
"host": "Hôte",
"location": "Localisation",
"actionLogs": "Journaux des actions",
"sidebarLogsRequest": "Journal des requêtes",
"sidebarLogsRequest": "Journal des Requêtes HTTP",
"sidebarLogsAccess": "Journaux d'accès",
"sidebarLogsAction": "Journaux des actions",
"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",
"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",
"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",
"logRetentionAccessLabel": "Rétention du journal d'accès",
"logRetentionAccessDescription": "Durée de conservation des journaux d'accès",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Transférer des événements directement sur votre compte Datadog. Prochainement.",
"streamingTypePickerDescription": "Choisissez un type de destination pour commencer.",
"streamingFailedToLoad": "Impossible de charger les destinations",
"streamingLastSyncError": "Une erreur s'est produite lors de la dernière synchronisation",
"streamingUnexpectedError": "Une erreur inattendue s'est produite.",
"streamingFailedToUpdate": "Impossible de mettre à jour la destination",
"streamingDeletedSuccess": "Destination supprimée avec succès",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Modifier la destination",
"S3DestAddTitle": "Ajouter une destination S3",
"S3DestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements S3.",
"S3DestAddDescription": "Configurer un nouveau point de terminaison S3 pour recevoir les événements de votre organisation.",
"S3DestAddDescription": "Configurez un nouveau bucket Amazon S3 (ou compatible S3) pour recevoir les événements de votre organisation.",
"s3DestTabSettings": "Réglages",
"s3DestTabFormat": "Format",
"s3DestNameLabel": "Nom",
"s3DestNamePlaceholder": "Ma destination S3",
"s3DestAccessKeyIdLabel": "ID de clé d'accès AWS",
"s3DestSecretAccessKeyLabel": "Clé d'accès secrète AWS",
"s3DestSecretAccessKeyPlaceholder": "Votre clé d'accès secrète AWS",
"s3DestRegionLabel": "Région AWS",
"s3DestBucketLabel": "Nom du bucket",
"s3DestPrefixLabel": "Préfixe clé (facultatif)",
"s3DestPrefixDescription": "Préfixe de chemin facultatif préfixé à chaque clé d'objet. Les objets sont stockés à {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Point de terminaison personnalisé (facultatif)",
"s3DestEndpointDescription": "Modifiez le point de terminaison S3 pour un stockage compatible S3 tel que MinIO ou Cloudflare R2. Laissez vide pour l'AWS S3 standard.",
"s3DestGzipLabel": "Compression Gzip",
"s3DestGzipDescription": "Compressez chaque objet téléchargé avec gzip. Réduit les coûts de stockage et la taille de téléchargement.",
"s3DestFormatTitle": "Format de fichier",
"s3DestFormatDescription": "Comment les événements sont sérialisés dans chaque objet téléchargé.",
"s3DestFormatJsonArrayDescription": "Chaque objet est un tableau JSON des enregistrements d'événements. Compatible avec la plupart des outils d'analyse.",
"s3DestFormatNdjsonDescription": "Chaque objet contient un enregistrement JSON par ligne (JSON délimité par saut de ligne). Compatible avec Athena, BigQuery et Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Chaque objet est un fichier CSV RFC-4180 avec une ligne d'en-tête. Les noms de colonne sont dérivés des champs de données de l'événement.",
"s3DestSaveChanges": "Enregistrer les modifications",
"s3DestCreateDestination": "Créer une destination",
"s3DestUpdatedSuccess": "Destination mise à jour avec succès",
"s3DestCreatedSuccess": "Destination créée avec succès",
"s3DestUpdateFailed": "Échec de la mise à jour de la destination",
"s3DestCreateFailed": "Échec de la création de la destination",
"datadogDestEditTitle": "Modifier la destination",
"datadogDestAddTitle": "Ajouter une destination Datadog",
"datadogDestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements Datadog.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.",
"httpDestConnectionLogsTitle": "Journaux de connexion",
"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.",
"httpDestSaveChanges": "Enregistrer les modifications",
"httpDestCreateDestination": "Créer une destination",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.",
"domainPickerWildcardCertWarningLink": "En savoir plus",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Si è verificato un errore durante l'eliminazione del link",
"shareDeleted": "Link eliminato",
"shareDeletedDescription": "Il link è stato eliminato",
"shareDelete": "Elimina Link di Condivisione",
"shareDeleteConfirm": "Conferma Eliminazione Link di Condivisione",
"shareQuestionRemove": "Sei sicuro di voler eliminare questo link di condivisione?",
"shareMessageRemove": "Una volta eliminato, il link non funzionerà più e chiunque lo utilizzi perderà l'accesso alla risorsa.",
"shareTokenDescription": "Il token di accesso può essere passato in due modi: come parametro di interrogazione o nelle intestazioni della richiesta. Questi devono essere passati dal client su ogni richiesta di accesso autenticato.",
"accessToken": "Token Di Accesso",
"usageExamples": "Esempi Di Utilizzo",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Una volta rimosso questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
"userRemoveOrgConfirm": "Conferma Rimozione Utente",
"userRemoveOrg": "Rimuovi Utente dall'Organizzazione",
"userQuestionOrgRemoveSelf": "Sei sicuro di voler rimuovere te stesso da questa organizzazione?",
"userMessageOrgRemoveSelf": "Perderai immediatamente l'accesso. Un amministratore può invitarti nuovamente in seguito, ma dovrai accettare un nuovo invito.",
"userRemoveOrgConfirmSelf": "Conferma Rimozione Me Stesso",
"userRemoveOrgSelf": "Rimuoviti dall'organizzazione",
"userRemoveOrgSelfWarning": "Perderai immediatamente l'accesso a questa organizzazione.",
"userRemoveOrgConfirmPhraseSelf": "RIMUOVITI DALL'ORGANIZZAZIONE",
"users": "Utenti",
"accessRoleMember": "Membro",
"accessRoleOwner": "Proprietario",
@@ -531,6 +541,11 @@
"emailInvalid": "Indirizzo email non valido",
"inviteValidityDuration": "Seleziona una durata",
"accessRoleSelectPlease": "Seleziona un ruolo",
"removeOwnAdminRoleConfirmTitle": "Rimuovere il tuo accesso amministrativo?",
"removeOwnAdminRoleConfirmDescription": "Non avrai più i permessi di amministratore in questa organizzazione dopo il salvataggio. Un altro amministratore può ripristinare l'accesso se necessario.",
"removeOwnAdminRoleConfirmButton": "Rimuovere il Mio Accesso Amministrativo",
"removeOwnAdminRoleConfirmPhrase": "RIMUOVERE IL MIO ACCESSO AMMINISTRATIVO",
"ownerMustRetainAdminRole": "Il proprietario dell'organizzazione deve mantenere almeno un ruolo di amministratore.",
"usernameRequired": "Username richiesto",
"idpSelectPlease": "Seleziona un provider di identità",
"idpGenericOidc": "Provider OAuth2/OIDC generico.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "L'aggiunta di più di un target abiliterà il bilanciamento del carico.",
"targetsSubmit": "Salva Target",
"addTarget": "Aggiungi Target",
"proxyMultiSiteRoundRobinNodeHelp": "Il routing round robin non funzionerà tra siti che non sono connessi allo stesso nodo, ma il failover funzionerà.",
"targetErrorInvalidIp": "Indirizzo IP non valido",
"targetErrorInvalidIpDescription": "Inserisci un indirizzo IP o un hostname valido",
"targetErrorInvalidPort": "Porta non valida",
@@ -2652,6 +2668,8 @@
"validPassword": "Password Valida",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Visualizza",
"configManaged": "Gestione Configurazione",
"connectedClient": "Cliente Connesso",
"resourceBlocked": "Risorsa Bloccata",
"droppedByRule": "Eliminato dalla regola",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP",
"reason": "Motivo",
"requestLogs": "Log Richiesta",
"requestLogs": "Log Richieste HTTP",
"requestAnalytics": "Richiedi Analisi",
"host": "Host",
"location": "Posizione",
"actionLogs": "Log Azioni",
"sidebarLogsRequest": "Log Richiesta",
"sidebarLogsRequest": "Log Richieste HTTP",
"sidebarLogsAccess": "Log Accesso",
"sidebarLogsAction": "Log Azioni",
"logRetention": "Ritenzione Registro",
"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",
"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",
"logRetentionAccessLabel": "Ritenzione Registro Accesso",
"logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Inoltra gli eventi direttamente al tuo account Datadog. In arrivo.",
"streamingTypePickerDescription": "Scegli un tipo di destinazione per iniziare.",
"streamingFailedToLoad": "Impossibile caricare le destinazioni",
"streamingLastSyncError": "Si è verificato un errore durante l'ultima sincronizzazione",
"streamingUnexpectedError": "Si è verificato un errore imprevisto.",
"streamingFailedToUpdate": "Impossibile aggiornare la destinazione",
"streamingDeletedSuccess": "Destinazione eliminata con successo",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Modifica Destinazione",
"S3DestAddTitle": "Aggiungi Destinazione S3",
"S3DestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi S3.",
"S3DestAddDescription": "Configura un nuovo endpoint S3 per ricevere gli eventi della tua organizzazione.",
"S3DestAddDescription": "Configura un nuovo bucket Amazon S3 (o compatibile con S3) per ricevere gli eventi della tua organizzazione.",
"s3DestTabSettings": "Impostazioni",
"s3DestTabFormat": "Formato",
"s3DestNameLabel": "Nome",
"s3DestNamePlaceholder": "La mia destinazione S3",
"s3DestAccessKeyIdLabel": "ID Chiave Accesso AWS",
"s3DestSecretAccessKeyLabel": "Chiave Segreta Accesso AWS",
"s3DestSecretAccessKeyPlaceholder": "La tua chiave segreta di accesso AWS",
"s3DestRegionLabel": "Regione AWS",
"s3DestBucketLabel": "Nome Bucket",
"s3DestPrefixLabel": "Prefisso Chiave (facoltativo)",
"s3DestPrefixDescription": "Prefisso percorso facoltativo anteposto a ogni chiave oggetto. Gli oggetti vengono archiviati in {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Endpoint personalizzato (facoltativo)",
"s3DestEndpointDescription": "Sostituisci l'endpoint S3 per lo storage compatibile con S3 come MinIO o Cloudflare R2. Lasciare vuoto per l'AWS S3 standard.",
"s3DestGzipLabel": "Compressione Gzip",
"s3DestGzipDescription": "Comprimi ogni oggetto caricato con gzip. Riduce i costi di archiviazione e la dimensione di caricamento.",
"s3DestFormatTitle": "Formato del File",
"s3DestFormatDescription": "Come gli eventi sono serializzati all'interno di ciascun oggetto caricato.",
"s3DestFormatJsonArrayDescription": "Ogni oggetto è un array JSON di record di eventi. Compatibile con la maggior parte degli strumenti analitici.",
"s3DestFormatNdjsonDescription": "Ogni oggetto contiene un record JSON per linea (JSON delimitato da newline). Compatibile con Athena, BigQuery e Spark.",
"s3DestFormatCsvTitle": "\"CSV\"",
"s3DestFormatCsvDescription": "Ogni oggetto è un file CSV RFC-4180 con una riga di intestazione. I nomi delle colonne sono derivati dai campi dei dati degli eventi.",
"s3DestSaveChanges": "Salva modifiche",
"s3DestCreateDestination": "Crea destinazione",
"s3DestUpdatedSuccess": "Destinazione aggiornata con successo",
"s3DestCreatedSuccess": "Destinazione creata con successo",
"s3DestUpdateFailed": "Aggiornamento della destinazione fallito",
"s3DestCreateFailed": "Creazione della destinazione fallita",
"datadogDestEditTitle": "Modifica Destinazione",
"datadogDestAddTitle": "Aggiungi Destinazione Datadog",
"datadogDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi Datadog.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.",
"httpDestConnectionLogsTitle": "Log Di Connessione",
"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.",
"httpDestSaveChanges": "Salva Modifiche",
"httpDestCreateDestination": "Crea Destinazione",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.",
"domainPickerWildcardCertWarningLink": "Scopri di più",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "링크 삭제 중 오류가 발생했습니다.",
"shareDeleted": "링크가 삭제되었습니다.",
"shareDeletedDescription": "링크가 삭제되었습니다.",
"shareDelete": "공유 링크 삭제",
"shareDeleteConfirm": "공유 링크 삭제 확인",
"shareQuestionRemove": "이 공유 링크를 삭제하시겠습니까?",
"shareMessageRemove": "삭제되면 링크가 더 이상 작동하지 않으며, 이를 사용하는 모든 사용자는 자원에 대한 접근을 잃게 됩니다.",
"shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더의 두 가지 방법으로 전달될 수 있습니다. 이는 인증된 액세스를 위해 클라이언트에서 모든 요청마다 전달되어야 합니다.",
"accessToken": "액세스 토큰",
"usageExamples": "사용 예",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "이 사용자가 제거되면 더 이상 조직에 접근할 수 없습니다. 나중에 다시 초대할 수 있지만, 초대를 다시 수락해야 합니다.",
"userRemoveOrgConfirm": "사용자 제거 확인",
"userRemoveOrg": "조직에서 사용자 제거",
"userQuestionOrgRemoveSelf": "이 조직에서 자신을 제거하시겠습니까?",
"userMessageOrgRemoveSelf": "귀하는 즉시 접근 권한을 잃게 됩니다. 관리자가 나중에 다시 초대할 수 있지만, 새 초대를 수락해야 합니다.",
"userRemoveOrgConfirmSelf": "내 제거 확인",
"userRemoveOrgSelf": "조직에서 자신을 제거하십시오",
"userRemoveOrgSelfWarning": "귀하는 이 조직에 대한 접근 권한을 즉시 상실합니다.",
"userRemoveOrgConfirmPhraseSelf": "조직에서 나를 제거",
"users": "사용자",
"accessRoleMember": "회원",
"accessRoleOwner": "소유자",
@@ -531,6 +541,11 @@
"emailInvalid": "유효하지 않은 이메일 주소입니다.",
"inviteValidityDuration": "지속 시간을 선택하십시오.",
"accessRoleSelectPlease": "역할을 선택하세요",
"removeOwnAdminRoleConfirmTitle": "관리자 권한을 제거하시겠습니까?",
"removeOwnAdminRoleConfirmDescription": "저장 후 이 조직에 대한 관리자 권한이 없어집니다. 필요한 경우 다른 관리자가 접근 권한을 복구할 수 있습니다.",
"removeOwnAdminRoleConfirmButton": "내 관리자 권한 제거",
"removeOwnAdminRoleConfirmPhrase": "내 관리자 권한 제거",
"ownerMustRetainAdminRole": "조직 소유자는 최소한 하나의 관리자 역할을 유지해야 합니다.",
"usernameRequired": "사용자 이름은 필수입니다.",
"idpSelectPlease": "신원 제공자를 선택하십시오",
"idpGenericOidc": "일반 OAuth2/OIDC 공급자.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "위에 하나 이상의 대상을 추가하면 로드 밸런싱이 활성화됩니다.",
"targetsSubmit": "대상 저장",
"addTarget": "대상 추가",
"proxyMultiSiteRoundRobinNodeHelp": "라운드 로빈 라우팅은 동일한 노드에 연결되지 않은 사이트 간에는 작동하지 않으나, 대체 라우팅은 작동합니다.",
"targetErrorInvalidIp": "유효하지 않은 IP 주소",
"targetErrorInvalidIpDescription": "유효한 IP 주소 또는 호스트 이름을 입력하세요.",
"targetErrorInvalidPort": "유효하지 않은 포트",
@@ -2652,6 +2668,8 @@
"validPassword": "유효한 비밀번호",
"validEmail": "유효한 이메일",
"validSSO": "유효한 SSO",
"view": "보기",
"configManaged": "구성 관리됨",
"connectedClient": "연결된 클라이언트",
"resourceBlocked": "리소스 차단됨",
"droppedByRule": "룰에 의해 드롭됨",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "유효한 인증 없음",
"ip": "IP",
"reason": "이유",
"requestLogs": "요청 로그",
"requestLogs": "HTTP 요청 로그",
"requestAnalytics": "요청 분석",
"host": "호스트",
"location": "위치",
"actionLogs": "작업 로그",
"sidebarLogsRequest": "요청 로그",
"sidebarLogsRequest": "HTTP 요청 로그",
"sidebarLogsAccess": "접근 로그",
"sidebarLogsAction": "작업 로그",
"logRetention": "로그 보관",
"logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다",
"requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다",
"requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기",
"logRetentionRequestLabel": "요청 로그 보관",
"logRetentionRequestLabel": "HTTP 요청 로그 보관",
"logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지",
"logRetentionAccessLabel": "접근 로그 보관",
"logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "데이터독",
"streamingDatadogDescription": "이벤트를 직접 Datadog 계정으로 전달합니다. 곧 제공됩니다.",
"streamingTypePickerDescription": "목표 유형을 선택하여 시작합니다.",
"streamingFailedToLoad": "대상 로드에 실패했습니다",
"streamingLastSyncError": "마지막 동기화에서 오류가 발생했습니다.",
"streamingUnexpectedError": "예기치 않은 오류가 발생했습니다.",
"streamingFailedToUpdate": "대상지를 업데이트하는 데 실패했습니다",
"streamingDeletedSuccess": "대상지가 성공적으로 삭제되었습니다",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "대상지 수정",
"S3DestAddTitle": "S3 대상지 추가",
"S3DestEditDescription": "이 S3 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
"S3DestAddDescription": "조직의 이벤트를 받기 위한 새로운 S3 엔드포인트를 구성하세요.",
"S3DestAddDescription": "조직의 이벤트를 수신할 새로운 Amazon S3(또는 S3 호환) 버킷을 구성하세요.",
"s3DestTabSettings": "설정",
"s3DestTabFormat": "형식",
"s3DestNameLabel": "이름",
"s3DestNamePlaceholder": "내 S3 대상",
"s3DestAccessKeyIdLabel": "AWS 액세스 키 ID",
"s3DestSecretAccessKeyLabel": "AWS 비밀 액세스 키",
"s3DestSecretAccessKeyPlaceholder": "귀하의 AWS 비밀 액세스 키",
"s3DestRegionLabel": "AWS 지역",
"s3DestBucketLabel": "버킷 이름",
"s3DestPrefixLabel": "키 접두사(선택 사항)",
"s3DestPrefixDescription": "하나의 객체 키 앞에 붙이는 선택적 경로 접두사입니다. 객체는 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}에 저장됩니다.",
"s3DestEndpointLabel": "사용자 정의 엔드포인트(선택 사항)",
"s3DestEndpointDescription": "MinIO 또는 Cloudflare R2와 같은 S3 호환 저장소에 대한 S3 엔드포인트를 재정의합니다. 표준 AWS S3의 경우 비워 두십시오.",
"s3DestGzipLabel": "Gzip 압축",
"s3DestGzipDescription": "각 업로드된 객체를 gzip으로 압축합니다. 저장 비용과 업로드 크기를 줄입니다.",
"s3DestFormatTitle": "파일 형식",
"s3DestFormatDescription": "업로드된 각 객체 내에서 이벤트가 직렬화되는 방식입니다.",
"s3DestFormatJsonArrayDescription": "각 객체는 이벤트 기록의 JSON 배열입니다. 대부분의 분석 도구와 호환됩니다.",
"s3DestFormatNdjsonDescription": "각 객체는 한 줄당 하나의 JSON 레코드를 포함합니다(새 줄로 구분된 JSON). Athena, BigQuery, Spark와 호환됩니다.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "각 객체는 헤더 행이 있는 RFC-4180 CSV 파일입니다. 열 이름은 이벤트 데이터 필드에서 파생됩니다.",
"s3DestSaveChanges": "변경 사항 저장",
"s3DestCreateDestination": "대상 생성",
"s3DestUpdatedSuccess": "대상이 성공적으로 업데이트되었습니다",
"s3DestCreatedSuccess": "대상이 성공적으로 생성되었습니다",
"s3DestUpdateFailed": "대상 업데이트에 실패했습니다",
"s3DestCreateFailed": "대상 생성에 실패했습니다",
"datadogDestEditTitle": "대상지 수정",
"datadogDestAddTitle": "Datadog 대상지 추가",
"datadogDestEditDescription": "이 Datadog 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.",
"httpDestConnectionLogsTitle": "연결 로그",
"httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.",
"httpDestRequestLogsTitle": "요청 로그",
"httpDestRequestLogsTitle": "HTTP 요청 로그",
"httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.",
"httpDestSaveChanges": "변경 사항 저장",
"httpDestCreateDestination": "대상지 생성",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.",
"domainPickerWildcardCertWarningLink": "자세히 알아보기",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "En feil oppstod ved sletting av lenke",
"shareDeleted": "Lenke slettet",
"shareDeletedDescription": "Lenken har blitt slettet",
"shareDelete": "Slett delingslenke",
"shareDeleteConfirm": "Bekreft sletting av delingslenke",
"shareQuestionRemove": "Er du sikker på at du vil slette denne delingslenken?",
"shareMessageRemove": "Når slettet, vil lenken ikke lenger fungere, og alle som bruker den vil miste tilgang til ressursen.",
"shareTokenDescription": "Adgangstoken kan sendes på to måter: som en spørringsparameter eller i forespørselsoverskriftene. Disse må sendes fra klienten på hver forespørsel om autentisert tilgang.",
"accessToken": "Tilgangsnøkkel",
"usageExamples": "Brukseksempler",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Når denne brukeren er fjernet, vil de ikke lenger ha tilgang til organisasjonen. Du kan alltid invitere dem på nytt senere, men de vil måtte godta invitasjonen på nytt.",
"userRemoveOrgConfirm": "Bekreft fjerning av bruker",
"userRemoveOrg": "Fjern bruker fra organisasjon",
"userQuestionOrgRemoveSelf": "Er du sikker på at du vil fjerne deg selv fra denne organisasjonen?",
"userMessageOrgRemoveSelf": "Du vil miste tilgang umiddelbart. En administrator kan invitere deg igjen senere, men du må godta en ny invitasjon.",
"userRemoveOrgConfirmSelf": "Bekreft fjerning av meg selv",
"userRemoveOrgSelf": "Fjern deg selv fra organisasjonen",
"userRemoveOrgSelfWarning": "Du vil miste tilgangen til denne organisasjonen umiddelbart.",
"userRemoveOrgConfirmPhraseSelf": "FJERN MEG SELV FRA ORG",
"users": "Brukere",
"accessRoleMember": "Medlem",
"accessRoleOwner": "Eier",
@@ -531,6 +541,11 @@
"emailInvalid": "Ugyldig e-postadresse",
"inviteValidityDuration": "Vennligst velg en varighet",
"accessRoleSelectPlease": "Vennligst velg en rolle",
"removeOwnAdminRoleConfirmTitle": "Fjern din administratoradgang?",
"removeOwnAdminRoleConfirmDescription": "Du vil ikke lenger ha administratorrettigheter i denne organisasjonen etter lagring. En annen administrator kan gjenopprette tilgang hvis nødvendig.",
"removeOwnAdminRoleConfirmButton": "Fjern min administratoradgang",
"removeOwnAdminRoleConfirmPhrase": "FJERN MIN ADMINISTRATORADGANG",
"ownerMustRetainAdminRole": "Organisasjonseier må beholde minst én administratorrolle.",
"usernameRequired": "Brukernavn er påkrevd",
"idpSelectPlease": "Vennligst velg en identitetsleverandør",
"idpGenericOidc": "Generisk OAuth2/OIDC-leverandør.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Å legge til mer enn ett mål ovenfor vil aktivere lastbalansering.",
"targetsSubmit": "Lagre mål",
"addTarget": "Legg til mål",
"proxyMultiSiteRoundRobinNodeHelp": "Rundkjøringrutefordeling vil ikke fungere mellom steder som ikke er koblet til samme node, men failover vil fungere.",
"targetErrorInvalidIp": "Ugyldig IP-adresse",
"targetErrorInvalidIpDescription": "Skriv inn en gyldig IP-adresse eller vertsnavn",
"targetErrorInvalidPort": "Ugyldig port",
@@ -2652,6 +2668,8 @@
"validPassword": "Gyldig passord",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Vis",
"configManaged": "Konfigurasjon administrert",
"connectedClient": "Tilkoblet klient",
"resourceBlocked": "Ressurs blokkert",
"droppedByRule": "Legg i regelen",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP",
"reason": "Grunn",
"requestLogs": "Forespørselslogger (Automatic Translation)",
"requestLogs": "HTTP-forespørselslogger",
"requestAnalytics": "Be om analyser",
"host": "Vert",
"location": "Sted",
"actionLogs": "Handlingslogger",
"sidebarLogsRequest": "Forespørselslogger (Automatic Translation)",
"sidebarLogsRequest": "HTTP-forespørselslogger",
"sidebarLogsAccess": "Tilgangslogger (Automatic Translation)",
"sidebarLogsAction": "Handlingslogger",
"logRetention": "Logg tilbaketrekning",
"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",
"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",
"logRetentionAccessLabel": "Få tilgang til loggoverføring",
"logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Videresend arrangementer direkte til din Datadog-konto. Kommer snart.",
"streamingTypePickerDescription": "Velg en måltype for å komme i gang.",
"streamingFailedToLoad": "Kan ikke laste inn destinasjoner",
"streamingLastSyncError": "Det oppstod en feil under siste synkronisering",
"streamingUnexpectedError": "En uventet feil oppstod.",
"streamingFailedToUpdate": "Kunne ikke oppdatere destinasjon",
"streamingDeletedSuccess": "Målet ble slettet",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Rediger destinasjon",
"S3DestAddTitle": "Legg til S3 destinasjon",
"S3DestEditDescription": "Oppdatere konfigurasjonen for denne S3-hendelsesstrømmingsdestinasjonen.",
"S3DestAddDescription": "Konfigurer et nytt S3-endepunkt for å motta organisasjonens hendelser.",
"S3DestAddDescription": "Konfigurer en ny Amazon S3 (eller S3-kompatibel) bucket for å motta din organisasjons hendelser.",
"s3DestTabSettings": "Innstillinger",
"s3DestTabFormat": "Format",
"s3DestNameLabel": "Navn",
"s3DestNamePlaceholder": "Min S3-destinasjon",
"s3DestAccessKeyIdLabel": "AWS tilgangsnøkkel-ID",
"s3DestSecretAccessKeyLabel": "AWS hemmelige tilgangsnøkkel",
"s3DestSecretAccessKeyPlaceholder": "Din AWS secret access key",
"s3DestRegionLabel": "AWS-region",
"s3DestBucketLabel": "Bucket-navn",
"s3DestPrefixLabel": "Nøkkelprefiks (valgfritt)",
"s3DestPrefixDescription": "Valgfritt bane-prefiks lagt til hver objektnøkkel. Objekter er lagret på {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Egendefinert endepunkt (valgfritt)",
"s3DestEndpointDescription": "Overstyr S3-endepunktet for S3-kompatibel lagring som MinIO eller Cloudflare R2. La stå tomt for standard AWS S3.",
"s3DestGzipLabel": "Gzip-komprimering",
"s3DestGzipDescription": "Komprimer hvert opplastede objekt med gzip. Reduserer lagringskostnader og opplastingsstørrelse.",
"s3DestFormatTitle": "Filformat",
"s3DestFormatDescription": "Hvordan hendelser er serialisert inni hvert opplastede objekt.",
"s3DestFormatJsonArrayDescription": "Hvert objekt er et JSON-array av hendelsesposter. Kompatibel med de fleste analyseverktøy.",
"s3DestFormatNdjsonDescription": "Hvert objekt inneholder en JSON-post per linje (nylinje-delt JSON). Kompatibel med Athena, BigQuery, og Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Hvert objekt er en RFC-4180 CSV-fil med en overskriftsrad. Kolonnenavn er avledet fra hendelsesdatafeltene.",
"s3DestSaveChanges": "Lagre endringer",
"s3DestCreateDestination": "Opprett destinasjon",
"s3DestUpdatedSuccess": "Destinasjon oppdatert vellykket",
"s3DestCreatedSuccess": "Destinasjon opprettet vellykket",
"s3DestUpdateFailed": "Kunne ikke oppdatere destinasjon",
"s3DestCreateFailed": "Kunne ikke opprette destinasjon",
"datadogDestEditTitle": "Rediger destinasjon",
"datadogDestAddTitle": "Legg til Datadog destinasjon",
"datadogDestEditDescription": "Oppdatere konfigurasjonen for denne Datadog-hendelsesstrømmingsdestinasjonen.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.",
"httpDestConnectionLogsTitle": "Loggfiler for tilkobling",
"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.",
"httpDestSaveChanges": "Lagre endringer",
"httpDestCreateDestination": "Opprett mål",
@@ -3174,7 +3219,7 @@
"publicIpEndpoint": "Endepunkt",
"lastTriggeredAt": "Siste utløste",
"reject": "Avvis",
"uptimeDaysAgo": "{count} days ago",
"uptimeDaysAgo": "{count} dager siden",
"uptimeToday": "I dag",
"uptimeNoDataAvailable": "Ingen data tilgjengelig",
"uptimeSuffix": "oppetid",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.",
"domainPickerWildcardCertWarningLink": "Lær mer",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Fout opgetreden tijdens het verwijderen link",
"shareDeleted": "Link verwijderd",
"shareDeletedDescription": "De link is verwijderd",
"shareDelete": "Verwijder Deel Link",
"shareDeleteConfirm": "Bevestig verwijdering van Deel Link",
"shareQuestionRemove": "Weet u zeker dat u deze deel link wilt verwijderen?",
"shareMessageRemove": "Zodra verwijderd, zal de link niet meer werken en zal iedereen die het gebruikt de toegang tot de bron verliezen.",
"shareTokenDescription": "De toegangstoken kan op twee manieren worden doorgegeven: als queryparameter of in de aanvraagheaders. Deze moeten worden doorgegeven van de client op elk verzoek voor geverifieerde toegang.",
"accessToken": "Toegangs-token",
"usageExamples": "Voorbeelden van gebruik",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Eenmaal verwijderd, heeft deze gebruiker geen toegang meer tot de organisatie. Je kunt ze later altijd opnieuw uitnodigen, maar ze zullen de uitnodiging opnieuw moeten accepteren.",
"userRemoveOrgConfirm": "Bevestig verwijderen gebruiker",
"userRemoveOrg": "Gebruiker uit organisatie verwijderen",
"userQuestionOrgRemoveSelf": "Weet u zeker dat u zichzelf uit deze organisatie wilt verwijderen?",
"userMessageOrgRemoveSelf": "U verliest onmiddellijk toegang. Een beheerder kan u later opnieuw uitnodigen, maar u moet een nieuwe uitnodiging accepteren.",
"userRemoveOrgConfirmSelf": "Bevestig Verwijder Mijn Persoon",
"userRemoveOrgSelf": "Verwijder uzelf uit de organisatie",
"userRemoveOrgSelfWarning": "U verliest onmiddellijk toegang tot deze organisatie.",
"userRemoveOrgConfirmPhraseSelf": "VERWIJDER MIJ UIT ORGANISATIE",
"users": "Gebruikers",
"accessRoleMember": "Lid",
"accessRoleOwner": "Eigenaar",
@@ -531,6 +541,11 @@
"emailInvalid": "Ongeldig e-mailadres",
"inviteValidityDuration": "Selecteer een tijdsduur",
"accessRoleSelectPlease": "Selecteer een rol",
"removeOwnAdminRoleConfirmTitle": "Uw beheerderstoegang verwijderen?",
"removeOwnAdminRoleConfirmDescription": "U zult na het opslaan geen beheerdersrechten meer hebben in deze organisatie. Een andere beheerder kan de toegang indien nodig herstellen.",
"removeOwnAdminRoleConfirmButton": "Verwijder Mijn Beheerderstoegang",
"removeOwnAdminRoleConfirmPhrase": "VERWIJDER MIJN BEHEERDERSTOEGANG",
"ownerMustRetainAdminRole": "De organisatie-eigenaar moet minstens één beheerdersrol behouden.",
"usernameRequired": "Gebruikersnaam is verplicht",
"idpSelectPlease": "Selecteer een identiteitsprovider",
"idpGenericOidc": "Algemene OAuth2/OIDC provider.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.",
"targetsSubmit": "Doelstellingen opslaan",
"addTarget": "Doelwit toevoegen",
"proxyMultiSiteRoundRobinNodeHelp": "Round-robin routering werkt niet tussen locaties die niet met hetzelfde knooppunt zijn verbonden, maar failover werkt wel.",
"targetErrorInvalidIp": "Ongeldig IP-adres",
"targetErrorInvalidIpDescription": "Voer een geldig IP-adres of hostnaam in",
"targetErrorInvalidPort": "Ongeldige poort",
@@ -2652,6 +2668,8 @@
"validPassword": "Geldig wachtwoord",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Bekijk",
"configManaged": "Configuratie Beheerd",
"connectedClient": "Verbonden Client",
"resourceBlocked": "Bron geblokkeerd",
"droppedByRule": "Achtergelaten door regel",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP-adres",
"reason": "Reden",
"requestLogs": "Logboeken aanvragen",
"requestLogs": "HTTP-aanvraaglogboeken",
"requestAnalytics": "Analytics opvragen",
"host": "Hostnaam",
"location": "Locatie",
"actionLogs": "Actie logs",
"sidebarLogsRequest": "Logboeken aanvragen",
"sidebarLogsRequest": "HTTP-aanvraaglogboeken",
"sidebarLogsAccess": "Toegang tot logboek",
"sidebarLogsAction": "Actie logs",
"logRetention": "Log bewaring",
"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",
"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",
"logRetentionAccessLabel": "Toegang logboek bewaring",
"logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Stuur gebeurtenissen rechtstreeks door naar je Datadog account. Binnenkort beschikbaar.",
"streamingTypePickerDescription": "Kies een bestemmingstype om te beginnen.",
"streamingFailedToLoad": "Laden van bestemmingen mislukt",
"streamingLastSyncError": "Er is een fout opgetreden bij de laatste synchronisatie",
"streamingUnexpectedError": "Er is een onverwachte fout opgetreden.",
"streamingFailedToUpdate": "Bijwerken bestemming mislukt",
"streamingDeletedSuccess": "Bestemming succesvol verwijderd",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Bestemming bewerken",
"S3DestAddTitle": "S3-bestemming toevoegen",
"S3DestEditDescription": "Werk de configuratie bij voor deze S3-gebeurtenisstreamingbestemming.",
"S3DestAddDescription": "Configureer een nieuw S3-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.",
"S3DestAddDescription": "Configureer een nieuwe Amazon S3 (of S3-compatibele) bucket om de gebeurtenissen van uw organisatie te ontvangen.",
"s3DestTabSettings": "Instellingen",
"s3DestTabFormat": "Formaat",
"s3DestNameLabel": "Naam",
"s3DestNamePlaceholder": "Mijn S3-bestemming",
"s3DestAccessKeyIdLabel": "AWS-toegangssleutel-ID",
"s3DestSecretAccessKeyLabel": "AWS Geheime Toegangssleutel",
"s3DestSecretAccessKeyPlaceholder": "Uw AWS geheime toegangssleutel",
"s3DestRegionLabel": "AWS-regio",
"s3DestBucketLabel": "Bucketnaam",
"s3DestPrefixLabel": "Sleutelvoorvoegsel (optioneel)",
"s3DestPrefixDescription": "Optioneel padvoorvoegsel dat aan elke object sleutel wordt toegevoegd. Objecten worden opgeslagen op {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Aangepast Eindpunt (optioneel)",
"s3DestEndpointDescription": "Overschrijf het S3-eindpunt voor S3-compatibele opslag zoals MinIO of Cloudflare R2. Laat leeg voor standaard AWS S3.",
"s3DestGzipLabel": "Gzip-compressie",
"s3DestGzipDescription": "Comprimeer elk geüpload object met gzip. Verlaagt opslagkosten en uploadgrootte.",
"s3DestFormatTitle": "Bestandsformaat",
"s3DestFormatDescription": "Hoe gebeurtenissen binnen elk geüpload object worden geserialiseerd.",
"s3DestFormatJsonArrayDescription": "Elk object is een JSON-array van gebeurtenisrecords. Compatibel met de meeste analysetools.",
"s3DestFormatNdjsonDescription": "Elk object bevat één JSON-record per regel (nieuwregel-gescheiden JSON). Compatibel met Athena, BigQuery en Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Elk object is een RFC-4180 CSV-bestand met een kopregel. Kolomnamen zijn afgeleid van de gebeurtenis gegevensvelden.",
"s3DestSaveChanges": "Wijzigingen opslaan",
"s3DestCreateDestination": "Bestemming maken",
"s3DestUpdatedSuccess": "Bestemming succesvol bijgewerkt",
"s3DestCreatedSuccess": "Bestemming succesvol gecreëerd",
"s3DestUpdateFailed": "Bijwerken bestemming mislukt",
"s3DestCreateFailed": "Aanmaken bestemming mislukt",
"datadogDestEditTitle": "Bestemming bewerken",
"datadogDestAddTitle": "Datadog-bestemming toevoegen",
"datadogDestEditDescription": "Werk de configuratie bij voor deze Datadog-gebeurtenisstreamingbestemming.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.",
"httpDestConnectionLogsTitle": "Connectie Logs",
"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.",
"httpDestSaveChanges": "Wijzigingen opslaan",
"httpDestCreateDestination": "Maak bestemming aan",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.",
"domainPickerWildcardCertWarningLink": "Meer informatie",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Wystąpił błąd podczas usuwania linku",
"shareDeleted": "Link usunięty",
"shareDeletedDescription": "Link został usunięty",
"shareDelete": "Usuń link udostępniania",
"shareDeleteConfirm": "Potwierdź usunięcie linku udostępniania",
"shareQuestionRemove": "Czy na pewno chcesz usunąć ten link udostępniania?",
"shareMessageRemove": "Po usunięciu, link przestanie działać i wszyscy korzystający z niego stracą dostęp do zasobu.",
"shareTokenDescription": "Token dostępu może być przekazywany na dwa sposoby: jako parametr zapytania lub w nagłówkach żądania. Muszą być przekazywane z klienta na każde żądanie uwierzytelnionego dostępu.",
"accessToken": "Token dostępu",
"usageExamples": "Przykłady użycia",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Po usunięciu ten użytkownik nie będzie miał już dostępu do organizacji. Zawsze możesz ponownie go zaprosić później, ale będzie musiał ponownie zaakceptować zaproszenie.",
"userRemoveOrgConfirm": "Potwierdź usunięcie użytkownika",
"userRemoveOrg": "Usuń użytkownika z organizacji",
"userQuestionOrgRemoveSelf": "Czy na pewno chcesz usunąć się z tej organizacji?",
"userMessageOrgRemoveSelf": "Stracisz dostęp natychmiastowo. Administrator może cię ponownie zaprosić, ale będziesz musiał przyjąć nowe zaproszenie.",
"userRemoveOrgConfirmSelf": "Potwierdź usunięcie siebie",
"userRemoveOrgSelf": "Usuń siebie z organizacji",
"userRemoveOrgSelfWarning": "Natychmiast stracisz dostęp do tej organizacji.",
"userRemoveOrgConfirmPhraseSelf": "USUŃ SIEBIE Z ORGANIZACJI",
"users": "Użytkownicy",
"accessRoleMember": "Członek",
"accessRoleOwner": "Właściciel",
@@ -531,6 +541,11 @@
"emailInvalid": "Nieprawidłowy adres e-mail",
"inviteValidityDuration": "Proszę wybrać okres ważności",
"accessRoleSelectPlease": "Proszę wybrać rolę",
"removeOwnAdminRoleConfirmTitle": "Usunąć dostęp administratora?",
"removeOwnAdminRoleConfirmDescription": "Po zapisaniu nie będziesz już posiadał uprawnień administratora w tej organizacji. Inny administrator może przywrócić dostęp, jeśli to konieczne.",
"removeOwnAdminRoleConfirmButton": "Usuń mój dostęp administratora",
"removeOwnAdminRoleConfirmPhrase": "USUŃ MÓJ DOSTĘP ADMINISTRATORA",
"ownerMustRetainAdminRole": "Właściciel organizacji musi zachować co najmniej jedną rolę administratora.",
"usernameRequired": "Nazwa użytkownika jest wymagana",
"idpSelectPlease": "Proszę wybrać dostawcę tożsamości",
"idpGenericOidc": "Ogólny dostawca OAuth2/OIDC.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Dodanie więcej niż jednego celu powyżej włączy równoważenie obciążenia.",
"targetsSubmit": "Zapisz cele",
"addTarget": "Dodaj cel",
"proxyMultiSiteRoundRobinNodeHelp": "Trasowanie round-robin nie będzie działać między witrynami, które nie są połączone z tym samym węzłem, ale przełączanie awaryjne będzie działać.",
"targetErrorInvalidIp": "Nieprawidłowy adres IP",
"targetErrorInvalidIpDescription": "Wprowadź prawidłowy adres IP lub nazwę hosta",
"targetErrorInvalidPort": "Nieprawidłowy port",
@@ -2652,6 +2668,8 @@
"validPassword": "Prawidłowe hasło",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Zobacz",
"configManaged": "Konfiguracja zarządzana",
"connectedClient": "Połączony Klient",
"resourceBlocked": "Zasób zablokowany",
"droppedByRule": "Upuszczone przez regułę",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP",
"reason": "Powód",
"requestLogs": "Dzienniki żądań",
"requestLogs": "Dzienniki żądań HTTP",
"requestAnalytics": "Żądanie Analityki",
"host": "Host",
"location": "Lokalizacja",
"actionLogs": "Dzienniki działań",
"sidebarLogsRequest": "Dzienniki żądań",
"sidebarLogsRequest": "Dzienniki żądań HTTP",
"sidebarLogsAccess": "Logi dostępu",
"sidebarLogsAction": "Dzienniki działań",
"logRetention": "Zachowanie dziennika",
"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",
"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ń",
"logRetentionAccessLabel": "Zachowanie dziennika dostępu",
"logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Przekaż wydarzenia bezpośrednio do Twojego konta Datadog. Już wkrótce.",
"streamingTypePickerDescription": "Wybierz typ docelowy, aby rozpocząć.",
"streamingFailedToLoad": "Nie udało się załadować miejsc docelowych",
"streamingLastSyncError": "Wystąpił błąd podczas ostatniej synchronizacji",
"streamingUnexpectedError": "Wystąpił nieoczekiwany błąd.",
"streamingFailedToUpdate": "Nie udało się zaktualizować miejsca docelowego",
"streamingDeletedSuccess": "Cel usunięty pomyślnie",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Edytuj Miejsce Docelowe",
"S3DestAddTitle": "Dodaj Miejsce Docelowe S3",
"S3DestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń S3.",
"S3DestAddDescription": "Skonfiguruj nowy punkt końcowy S3, aby odbierać zdarzenia Twojej organizacji.",
"S3DestAddDescription": "Skonfiguruj nowy zasobnik Amazon S3 (lub zgodny z S3), aby otrzymywać zdarzenia twojej organizacji.",
"s3DestTabSettings": "Ustawienia",
"s3DestTabFormat": "Format",
"s3DestNameLabel": "Nazwa",
"s3DestNamePlaceholder": "Moje miejsce docelowe S3",
"s3DestAccessKeyIdLabel": "AWS Access Key ID",
"s3DestSecretAccessKeyLabel": "AWS Secret Access Key",
"s3DestSecretAccessKeyPlaceholder": "Twój AWS Secret Access Key",
"s3DestRegionLabel": "Region AWS",
"s3DestBucketLabel": "Nazwa kubła",
"s3DestPrefixLabel": "Prefiks klucza (opcjonalnie)",
"s3DestPrefixDescription": "Opcjonalny prefiks ścieżki dołączony do każdego klucza obiektu. Obiekty są przechowywane w {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Niestandardowy punkt końcowy (opcjonalnie)",
"s3DestEndpointDescription": "Nadpisz punkt końcowy S3 dla zgodnego przechowywania danych, takiego jak MinIO lub Cloudflare R2. Pozostaw puste dla standardowego AWS S3.",
"s3DestGzipLabel": "Kompresja Gzip",
"s3DestGzipDescription": "Skompresuj każdy przesłany obiekt za pomocą gzip. Zmniejsza koszty przechowywania i rozmiar przesyłu.",
"s3DestFormatTitle": "Format pliku",
"s3DestFormatDescription": "Jak zdarzenia są serializowane w każdym przesłanym obiekcie.",
"s3DestFormatJsonArrayDescription": "Każdy obiekt to tablica JSON z rekordami zdarzeń. Zgodne z większością narzędzi analitycznych.",
"s3DestFormatNdjsonDescription": "Każdy obiekt zawiera jeden rekord JSON na linię (nowa linia-dzielone JSON). Zgodne z Athena, BigQuery i Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Każdy obiekt to plik CSV zgodny z RFC-4180 z wierszem nagłówka. Nazwy kolumn pochodzą z pól danych zdarzeń.",
"s3DestSaveChanges": "Zapisz zmiany",
"s3DestCreateDestination": "Utwórz miejsce docelowe",
"s3DestUpdatedSuccess": "Miejsce docelowe zaktualizowane pomyślnie",
"s3DestCreatedSuccess": "Miejsce docelowe utworzone pomyślnie",
"s3DestUpdateFailed": "Nie udało się zaktualizować miejsca docelowego",
"s3DestCreateFailed": "Nie udało się utworzyć miejsca docelowego",
"datadogDestEditTitle": "Edytuj Miejsce Docelowe",
"datadogDestAddTitle": "Dodaj Miejsce Docelowe Datadog",
"datadogDestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń Datadog.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.",
"httpDestConnectionLogsTitle": "Dzienniki połączeń",
"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.",
"httpDestSaveChanges": "Zapisz zmiany",
"httpDestCreateDestination": "Utwórz cel",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.",
"domainPickerWildcardCertWarningLink": "Dowiedz się więcej",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Ocorreu um erro ao apagar o link",
"shareDeleted": "Link excluído",
"shareDeletedDescription": "O link foi eliminado",
"shareDelete": "Excluir Link de Compartilhamento",
"shareDeleteConfirm": "Confirmar Exclusão de Link de Compartilhamento",
"shareQuestionRemove": "Tem certeza de que deseja excluir este link de compartilhamento?",
"shareMessageRemove": "Uma vez excluído, o link não funcionará mais e qualquer pessoa que o utilizar perderá o acesso ao recurso.",
"shareTokenDescription": "O token de acesso pode ser passado de duas maneiras: como um parâmetro de consulta ou nos cabeçalhos da solicitação. Estes devem ser passados do cliente em todas as solicitações para acesso autenticado.",
"accessToken": "Token de acesso",
"usageExamples": "Exemplos de uso",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Uma vez removido, este utilizador não terá mais acesso à organização. Você sempre pode reconvidá-lo depois, mas eles precisarão aceitar o convite novamente.",
"userRemoveOrgConfirm": "Confirmar Remoção do Usuário",
"userRemoveOrg": "Remover Usuário da Organização",
"userQuestionOrgRemoveSelf": "Tem certeza de que deseja se remover desta organização?",
"userMessageOrgRemoveSelf": "Você perderá o acesso imediatamente. Um administrador poderá convidá-lo novamente mais tarde, mas você precisará aceitar um novo convite.",
"userRemoveOrgConfirmSelf": "Confirmar a Remoção de Mim Mesmo",
"userRemoveOrgSelf": "Remover-se da organização",
"userRemoveOrgSelfWarning": "Você perderá o acesso a esta organização imediatamente.",
"userRemoveOrgConfirmPhraseSelf": "REMOVER-ME DA ORG",
"users": "Utilizadores",
"accessRoleMember": "Membro",
"accessRoleOwner": "Proprietário",
@@ -531,6 +541,11 @@
"emailInvalid": "Endereço de email inválido",
"inviteValidityDuration": "Por favor, selecione uma duração",
"accessRoleSelectPlease": "Por favor, selecione uma função",
"removeOwnAdminRoleConfirmTitle": "Remover seu acesso de administrador?",
"removeOwnAdminRoleConfirmDescription": "Você não terá mais permissões de administrador nesta organização após salvar. Outro administrador pode restaurar seu acesso, se necessário.",
"removeOwnAdminRoleConfirmButton": "Remover Meu Acesso de Administrador",
"removeOwnAdminRoleConfirmPhrase": "REMOVER MEU ACESSO DE ADMIN",
"ownerMustRetainAdminRole": "O proprietário da organização deve manter pelo menos um papel de administrador.",
"usernameRequired": "Nome de utilizador é obrigatório",
"idpSelectPlease": "Por favor, selecione um provedor de identidade",
"idpGenericOidc": "Provedor genérico OAuth2/OIDC.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Adicionar mais de um alvo acima habilitará o balanceamento de carga.",
"targetsSubmit": "Guardar Alvos",
"addTarget": "Adicionar Alvo",
"proxyMultiSiteRoundRobinNodeHelp": "O roteamento round robin não funcionará entre sites que não estão conectados ao mesmo nó, mas o failover funcionará.",
"targetErrorInvalidIp": "Endereço IP inválido",
"targetErrorInvalidIpDescription": "Por favor, insira um endereço IP ou nome de host válido",
"targetErrorInvalidPort": "Porta inválida",
@@ -2652,6 +2668,8 @@
"validPassword": "Senha válida",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Visualizar",
"configManaged": "Configuração Gerenciada",
"connectedClient": "Cliente Conectado",
"resourceBlocked": "Recurso bloqueado",
"droppedByRule": "Derrubado pela regra",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "PI",
"reason": "Motivo",
"requestLogs": "Registro de pedidos",
"requestLogs": "Registros de Pedidos HTTP",
"requestAnalytics": "Solicitar análise",
"host": "Servidor",
"location": "Local:",
"actionLogs": "Logs de Ações",
"sidebarLogsRequest": "Registro de pedidos",
"sidebarLogsRequest": "Registros de Pedidos HTTP",
"sidebarLogsAccess": "Logs de Acesso",
"sidebarLogsAction": "Logs de Ações",
"logRetention": "Retenção de Log",
"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",
"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",
"logRetentionAccessLabel": "Retenção de Log de Acesso",
"logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Encaminha eventos diretamente para a sua conta no Datadog. Em breve.",
"streamingTypePickerDescription": "Escolha um tipo de destino para começar.",
"streamingFailedToLoad": "Falha ao carregar destinos",
"streamingLastSyncError": "Ocorreu um erro na última sincronização",
"streamingUnexpectedError": "Ocorreu um erro inesperado.",
"streamingFailedToUpdate": "Falha ao atualizar destino",
"streamingDeletedSuccess": "Destino apagado com sucesso",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Editar Destino",
"S3DestAddTitle": "Adicionar Destino S3",
"S3DestEditDescription": "Atualize a configuração para este destino de streaming de eventos S3.",
"S3DestAddDescription": "Configure um novo endpoint S3 para receber os eventos da sua organização.",
"S3DestAddDescription": "Configure um novo bucket Amazon S3 (ou compatível com S3) para receber os eventos da sua organização.",
"s3DestTabSettings": "Configurações",
"s3DestTabFormat": "Formato",
"s3DestNameLabel": "Nome",
"s3DestNamePlaceholder": "Meu destino S3",
"s3DestAccessKeyIdLabel": "ID da Chave de Acesso AWS",
"s3DestSecretAccessKeyLabel": "Chave de Acesso Secreta AWS",
"s3DestSecretAccessKeyPlaceholder": "Sua chave de acesso secreta AWS",
"s3DestRegionLabel": "Região AWS",
"s3DestBucketLabel": "Nome do Bucket",
"s3DestPrefixLabel": "Prefixo da Chave (opcional)",
"s3DestPrefixDescription": "Prefixo de caminho opcional adicionado a cada chave de objeto. Os objetos são armazenados em {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Endpoint Personalizado (opcional)",
"s3DestEndpointDescription": "Substitua o endpoint S3 por armazenamento compatível com S3, como MinIO ou Cloudflare R2. Deixe em branco para o padrão AWS S3.",
"s3DestGzipLabel": "Compressão Gzip",
"s3DestGzipDescription": "Comprime cada objeto carregado com gzip. Reduz custos de armazenamento e tamanho de upload.",
"s3DestFormatTitle": "Formato de Arquivo",
"s3DestFormatDescription": "Como os eventos são serializados dentro de cada objeto carregado.",
"s3DestFormatJsonArrayDescription": "Cada objeto é um array JSON de registros de eventos. Compatível com a maioria das ferramentas de análise.",
"s3DestFormatNdjsonDescription": "Cada objeto contém um registro JSON por linha (JSON delimitado por nova linha). Compatível com Athena, BigQuery e Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Cada objeto é um arquivo CSV RFC-4180 com uma linha de cabeçalho. Nomes de colunas são derivados dos campos de dados do evento.",
"s3DestSaveChanges": "Salvar Alterações",
"s3DestCreateDestination": "Criar Destino",
"s3DestUpdatedSuccess": "Destino atualizado com sucesso",
"s3DestCreatedSuccess": "Destino criado com sucesso",
"s3DestUpdateFailed": "Falha ao atualizar destino",
"s3DestCreateFailed": "Falha ao criar destino",
"datadogDestEditTitle": "Editar Destino",
"datadogDestAddTitle": "Adicionar Destino Datadog",
"datadogDestEditDescription": "Atualize a configuração para este destino de streaming de eventos Datadog.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.",
"httpDestConnectionLogsTitle": "Logs da conexão",
"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.",
"httpDestSaveChanges": "Salvar as alterações",
"httpDestCreateDestination": "Criar destino",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.",
"domainPickerWildcardCertWarningLink": "Saiba mais",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Произошла ошибка при удалении ссылки",
"shareDeleted": "Ссылка удалена",
"shareDeletedDescription": "Ссылка была успешно удалена",
"shareDelete": "Удалить общую ссылку",
"shareDeleteConfirm": "Подтвердите удаление общей ссылки",
"shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?",
"shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.",
"shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.",
"accessToken": "Токен доступа",
"usageExamples": "Примеры использования",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "После удаления этот пользователь больше не будет иметь доступ к организации. Вы всегда можете пригласить его заново, но ему нужно будет снова принять приглашение.",
"userRemoveOrgConfirm": "Подтвердить удаление пользователя",
"userRemoveOrg": "Удалить пользователя из организации",
"userQuestionOrgRemoveSelf": "Вы уверены, что хотите удалить себя из этой организации?",
"userMessageOrgRemoveSelf": "Вы немедленно потеряете доступ. Администратор сможет снова пригласить вас позже, но вам нужно будет принять новое приглашение.",
"userRemoveOrgConfirmSelf": "Подтвердите удаление себя",
"userRemoveOrgSelf": "Удалите себя из организации",
"userRemoveOrgSelfWarning": "Вы немедленно потеряете доступ к этой организации.",
"userRemoveOrgConfirmPhraseSelf": "Удалить себя из организации",
"users": "Пользователи",
"accessRoleMember": "Участник",
"accessRoleOwner": "Владелец",
@@ -531,6 +541,11 @@
"emailInvalid": "Неверный адрес Email",
"inviteValidityDuration": "Пожалуйста, выберите продолжительность",
"accessRoleSelectPlease": "Пожалуйста, выберите роль",
"removeOwnAdminRoleConfirmTitle": "Удалить доступ администратора?",
"removeOwnAdminRoleConfirmDescription": "После сохранения у вас больше не будет прав администратора в этой организации. Другой администратор может восстановить доступ, если это необходимо.",
"removeOwnAdminRoleConfirmButton": "Удалить мой доступ администратора",
"removeOwnAdminRoleConfirmPhrase": "УДАЛИТЬ МОЙ ДОСТУП АДМИНИСТРАТОРА",
"ownerMustRetainAdminRole": "Владелец организации должен сохранить по крайней мере одну роль администратора.",
"usernameRequired": "Имя пользователя обязательно",
"idpSelectPlease": "Пожалуйста, выберите Identity Provider",
"idpGenericOidc": "Обычный OAuth2/OIDC provider.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.",
"targetsSubmit": "Сохранить цели",
"addTarget": "Добавить цель",
"proxyMultiSiteRoundRobinNodeHelp": "Роутинг с балансировкой нагрузки не будет работать между сайтами, не подключенными к одному и тому же узлу, но подмена будет работать.",
"targetErrorInvalidIp": "Неверный IP-адрес",
"targetErrorInvalidIpDescription": "Пожалуйста, введите действительный IP адрес или имя хоста",
"targetErrorInvalidPort": "Неверный порт",
@@ -2652,6 +2668,8 @@
"validPassword": "Допустимый пароль",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "Просмотр",
"configManaged": "Конфигурация управляется",
"connectedClient": "Подключенный клиент",
"resourceBlocked": "Ресурс заблокирован",
"droppedByRule": "Отброшено по правилам",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "No Valid Auth",
"ip": "IP",
"reason": "Причина",
"requestLogs": "Запросить журналы",
"requestLogs": "HTTP Запросы Логи",
"requestAnalytics": "Аналитика запроса",
"host": "Хост",
"location": "Местоположение",
"actionLogs": "Журнал действий",
"sidebarLogsRequest": "Запросить журналы",
"sidebarLogsRequest": "HTTP Запросы Логи",
"sidebarLogsAccess": "Журналы доступа",
"sidebarLogsAction": "Журнал действий",
"logRetention": "Сохранение журнала",
"logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их",
"requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации",
"requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации",
"logRetentionRequestLabel": "Запросить сохранение журнала",
"logRetentionRequestLabel": "Сохранение HTTP Запросов Лога",
"logRetentionRequestDescription": "Как долго сохранять журналы запросов",
"logRetentionAccessLabel": "Хранение журнала доступа",
"logRetentionAccessDescription": "Как долго сохранять журналы доступа",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Перенаправлять события непосредственно на ваш аккаунт в Datadog. Скоро будет доступно.",
"streamingTypePickerDescription": "Выберите тип назначения, чтобы начать.",
"streamingFailedToLoad": "Не удалось загрузить места назначения",
"streamingLastSyncError": "Во время последней синхронизации произошла ошибка",
"streamingUnexpectedError": "Произошла непредвиденная ошибка.",
"streamingFailedToUpdate": "Не удалось обновить место назначения",
"streamingDeletedSuccess": "Адрес назначения успешно удален",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Редактировать пункт назначения",
"S3DestAddTitle": "Добавить S3 пункт назначения",
"S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.",
"S3DestAddDescription": "Настройте новую S3 конечную точку для получения событий вашей организации.",
"S3DestAddDescription": "Настройте новый Amazon S3 (или совместимое S3) хранилище для получения событий вашей организации.",
"s3DestTabSettings": "Настройки",
"s3DestTabFormat": "Формат",
"s3DestNameLabel": "Имя",
"s3DestNamePlaceholder": "Моя S3 конечная точка",
"s3DestAccessKeyIdLabel": "Идентификатор ключа доступа AWS",
"s3DestSecretAccessKeyLabel": "Секретный ключ доступа AWS",
"s3DestSecretAccessKeyPlaceholder": "Ваш секретный ключ доступа AWS",
"s3DestRegionLabel": "Регион AWS",
"s3DestBucketLabel": "Имя хранилища",
"s3DestPrefixLabel": "Префикс ключа (по желанию)",
"s3DestPrefixDescription": "Необязательный префикс пути, добавляется к каждому ключу объекта. Объекты хранятся в {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
"s3DestEndpointLabel": "Пользовательская конечная точка (по желанию)",
"s3DestEndpointDescription": "Переопределите конечную точку S3 для совместимого хранилища, такого как MinIO или Cloudflare R2. Оставьте пустым для стандартного AWS S3.",
"s3DestGzipLabel": "Сжатие Gzip",
"s3DestGzipDescription": "Сжимайте каждый загруженный объект с помощью gzip. Уменьшает стоимость хранения и размер загрузки.",
"s3DestFormatTitle": "Формат файла",
"s3DestFormatDescription": "Как события сериализуются внутри каждого загруженного объекта.",
"s3DestFormatJsonArrayDescription": "Каждый объект — это JSON массив записей событий. Совместим с большинством аналитических инструментов.",
"s3DestFormatNdjsonDescription": "Каждый объект содержит одну запись JSON на строку (JSON, разделённый новой строкой). Совместим с Athena, BigQuery и Spark.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Каждый объект представляет собой CSV файл по стандарту RFC-4180 с заголовочной строкой. Имена столбцов выведены из полей данных событий.",
"s3DestSaveChanges": "Сохранить изменения",
"s3DestCreateDestination": "Создать конечную точку",
"s3DestUpdatedSuccess": "Конечная точка успешно обновлена",
"s3DestCreatedSuccess": "Конечная точка успешно создана",
"s3DestUpdateFailed": "Не удалось обновить конечную точку",
"s3DestCreateFailed": "Не удалось создать конечную точку",
"datadogDestEditTitle": "Редактировать пункт назначения",
"datadogDestAddTitle": "Добавить пункт назначения Datadog",
"datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.",
"httpDestConnectionLogsTitle": "Журнал подключений",
"httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.",
"httpDestRequestLogsTitle": "Запросить журналы",
"httpDestRequestLogsTitle": "HTTP Запросы Логи",
"httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.",
"httpDestSaveChanges": "Сохранить изменения",
"httpDestCreateDestination": "Создать адрес назначения",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.",
"domainPickerWildcardCertWarningLink": "Узнать больше",
"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

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "Bağlantı silinirken bir hata oluştu",
"shareDeleted": "Bağlantı silindi",
"shareDeletedDescription": "Bağlantı silindi",
"shareDelete": "Paylaşım Bağlantısını Sil",
"shareDeleteConfirm": "Paylaşım Bağlantısının Silinmesini Onayla",
"shareQuestionRemove": "Bu paylaşım bağlantısını silmek istediğinizden emin misiniz?",
"shareMessageRemove": "Silindikten sonra, bağlantı artık çalışmayacak ve kullanan herkes kaynağa erişimini kaybedecek.",
"shareTokenDescription": "Erişim jetonunuz iki şekilde iletilebilir: sorgu parametresi olarak veya istek başlıklarında. Kimlik doğrulanmış erişim için her istekten müşteri tarafından iletilmelidir.",
"accessToken": "Erişim Jetonu",
"usageExamples": "Kullanım Örnekleri",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Kaldırıldığında, bu kullanıcı organizasyona artık erişim sağlayamayacak. Kullanıcı tekrar davet edilebilir, ancak daveti kabul etmesi gerekecek.",
"userRemoveOrgConfirm": "Kullanıcıyı Kaldırmayı Onayla",
"userRemoveOrg": "Kullanıcıyı Organizasyondan Kaldır",
"userQuestionOrgRemoveSelf": "Bu organizasyondan kendinizi kaldırmak istediğinizden emin misiniz?",
"userMessageOrgRemoveSelf": "Erişiminizi hemen kaybedeceksiniz. Bir yönetici daha sonra sizi tekrar davet edebilir, ancak yeni bir daveti kabul etmeniz gerekecek.",
"userRemoveOrgConfirmSelf": "Kendimi Kaldırmayı Onayla",
"userRemoveOrgSelf": "Kendinizi organizasyondan kaldırın",
"userRemoveOrgSelfWarning": "Bu organizasyona erişiminizi anında kaybedeceksiniz.",
"userRemoveOrgConfirmPhraseSelf": "KENDİMİ ORGANİZASYONDAN KALDIR",
"users": "Kullanıcılar",
"accessRoleMember": "Üye",
"accessRoleOwner": "Sahip",
@@ -531,6 +541,11 @@
"emailInvalid": "Geçersiz e-posta adresi",
"inviteValidityDuration": "Lütfen bir süre seçin",
"accessRoleSelectPlease": "Lütfen bir rol seçin",
"removeOwnAdminRoleConfirmTitle": "Yönetici erişiminizi kaldırmak istiyor musunuz?",
"removeOwnAdminRoleConfirmDescription": "Kaydettikten sonra, bu organizasyonda artık yönetici izinleriniz olmayacak. Gerekirse başka bir yönetici erişimi geri yükleyebilir.",
"removeOwnAdminRoleConfirmButton": "Yönetici Erişimi Kaldır",
"removeOwnAdminRoleConfirmPhrase": "YÖNETİCİ ERİŞİMİMİ KALDIR",
"ownerMustRetainAdminRole": "Organizasyon sahibi en az bir yönetici rolü bulundurmalıdır.",
"usernameRequired": "Kullanıcı adı gereklidir",
"idpSelectPlease": "Lütfen bir kimlik sağlayıcı seçin",
"idpGenericOidc": "Genel OAuth2/OIDC sağlayıcısı.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Yukarıdaki birden fazla hedef ekleyerek yük dengeleme etkinleştirilecektir.",
"targetsSubmit": "Hedefleri Kaydet",
"addTarget": "Hedef Ekle",
"proxyMultiSiteRoundRobinNodeHelp": "Round robin yönlendirme, aynı düğüme bağlı olmayan siteler arasında çalışmayacaktır, ancak failover çalışacaktır.",
"targetErrorInvalidIp": "Geçersiz IP adresi",
"targetErrorInvalidIpDescription": "Lütfen geçerli bir IP adresi veya host adı girin",
"targetErrorInvalidPort": "Geçersiz port",
@@ -2652,6 +2668,8 @@
"validPassword": "Geçerli Şifre",
"validEmail": "Geçerli E-posta",
"validSSO": "Geçerli SSO",
"view": "Görüntüle",
"configManaged": "Yapılandırma Yönetildi",
"connectedClient": "Bağlı İstemci",
"resourceBlocked": "Kaynak Engellendi",
"droppedByRule": "Kurallara Göre Çıkartıldı",
@@ -2660,19 +2678,19 @@
"noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok",
"ip": "IP",
"reason": "Sebep",
"requestLogs": "İstek Günlükleri",
"requestLogs": "HTTP İstek Günlükleri",
"requestAnalytics": "İstek Analizi",
"host": "Sunucu",
"location": "Konum",
"actionLogs": "Eylem Günlükleri",
"sidebarLogsRequest": "İstek Günlükleri",
"sidebarLogsRequest": "HTTP İstek Günlükleri",
"sidebarLogsAccess": "Erişim Günlükleri",
"sidebarLogsAction": "Eylem Günlükleri",
"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",
"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.",
"logRetentionRequestLabel": "İstek Günlüğü Saklama",
"logRetentionRequestLabel": "HTTP İstek Günlüğü Saklama",
"logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle",
"logRetentionAccessLabel": "Erişim Günlüğü Saklama",
"logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "Olayları doğrudan Datadog hesabınıza iletin. Yakında gelicek.",
"streamingTypePickerDescription": "Başlamak için bir hedef türü seçin.",
"streamingFailedToLoad": "Hedefler yüklenemedi",
"streamingLastSyncError": "Son senkronizasyonda bir hata oluştu",
"streamingUnexpectedError": "Beklenmeyen bir hata oluştu.",
"streamingFailedToUpdate": "Hedef güncellenemedi",
"streamingDeletedSuccess": "Hedef başarıyla silindi",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "Hedefi Düzenle",
"S3DestAddTitle": "S3 Hedefi Ekle",
"S3DestEditDescription": "Bu S3 olay akışı hedefi için yapılandırmayı güncelleyin.",
"S3DestAddDescription": "Kuruluşunuzun olaylarını almak için yeni bir S3 uç noktası yapılandırın.",
"S3DestAddDescription": "Kuruluşunuzun etkinliklerini almak için yeni bir Amazon S3 (veya S3-uyumlu) kovası yapılandırın.",
"s3DestTabSettings": "Ayarlar",
"s3DestTabFormat": "Biçim",
"s3DestNameLabel": "Ad",
"s3DestNamePlaceholder": "Benim S3 hedefim",
"s3DestAccessKeyIdLabel": "AWS Erişim Anahtar Kimliği",
"s3DestSecretAccessKeyLabel": "AWS Gizli Erişim Anahtarı",
"s3DestSecretAccessKeyPlaceholder": "AWS gizli erişim anahtarınız",
"s3DestRegionLabel": "AWS Bölgesi",
"s3DestBucketLabel": "Kova Adı",
"s3DestPrefixLabel": "Anahtar Ön Eki (isteğe bağlı)",
"s3DestPrefixDescription": "Her nesne anahtarının önüne eklenen isteğe bağlı yol öneki. Nesneler {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename} konumunda saklanır.",
"s3DestEndpointLabel": "Özel Uç Nokta (isteğe bağlı)",
"s3DestEndpointDescription": "MinIO veya Cloudflare R2 gibi S3-uyumlu depolama için S3 uç noktasını geçersiz kılın. Standart AWS S3 için boş bırakın.",
"s3DestGzipLabel": "Gzip sıkıştırması",
"s3DestGzipDescription": "Her yüklü nesneyi gzip ile sıkıştırın. Depolama maliyetlerini ve yükleme boyutunu azaltır.",
"s3DestFormatTitle": "Dosya Biçimi",
"s3DestFormatDescription": "Etkinliklerin her yüklendiği nesne içinde nasıl serileştirildiği.",
"s3DestFormatJsonArrayDescription": "Her nesne bir olay kayıtlarının JSON dizisidir. Çoğu analiz aracıyla uyumludur.",
"s3DestFormatNdjsonDescription": "Her nesne satır başına bir JSON kaydı içerir (yeni satır ile ayrılmış JSON). Athena, BigQuery ve Spark ile uyumludur.",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "Her nesne, bir başlık satırı ile birlikte RFC-4180 CSV dosyasıdır. Sütun isimleri olay verileri alanlarından türetilmiştir.",
"s3DestSaveChanges": "Değişiklikleri Kaydet",
"s3DestCreateDestination": "Hedef Oluştur",
"s3DestUpdatedSuccess": "Hedef başarıyla güncellendi",
"s3DestCreatedSuccess": "Hedef başarıyla oluşturuldu",
"s3DestUpdateFailed": "Hedef güncellenemedi",
"s3DestCreateFailed": "Hedef oluşturulamadı",
"datadogDestEditTitle": "Hedefi Düzenle",
"datadogDestAddTitle": "Datadog Hedefi Ekle",
"datadogDestEditDescription": "Bu Datadog olay akışı hedefi için yapılandırmayı güncelleyin.",
@@ -3134,7 +3179,7 @@
"httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.",
"httpDestConnectionLogsTitle": "Bağlantı Kayıtları",
"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.",
"httpDestSaveChanges": "Değişiklikleri Kaydet",
"httpDestCreateDestination": "Hedef Oluştur",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.",
"domainPickerWildcardCertWarningLink": "Daha fazla bilgi",
"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

@@ -32,7 +32,7 @@
"trialActive": "免费试用中",
"trialExpired": "试用到期",
"trialHasEnded": "您的试用已结束。",
"trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}",
"trialDaysRemaining": "{count, plural, other {# 天剩余}}",
"trialDaysLeftShort": "试用期剩余 {days} 天",
"trialGoToBilling": "转到账单页面",
"subscriptionViolationViewBilling": "查看计费",
@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "删除链接时出错",
"shareDeleted": "链接已删除",
"shareDeletedDescription": "链接已删除",
"shareDelete": "删除共享链接",
"shareDeleteConfirm": "确认删除共享链接",
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
"accessToken": "访问令牌",
"usageExamples": "用法示例",
@@ -303,7 +307,7 @@
"accessUserManage": "管理用户",
"accessUsersDescription": "邀请和管理访问此组织的用户",
"accessUsersSearch": "搜索用户...",
"accessUsersRoleFilterCount": "{count, plural, one {# role} other {# roles}}",
"accessUsersRoleFilterCount": "{count, plural, other {# 角色}}",
"accessUsersRoleFilterClear": "清除角色过滤器",
"accessUserCreate": "创建用户",
"accessUserRemove": "删除用户",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "一旦删除,这个用户将不再能够访问组织。 你总是可以稍后重新邀请他们,但他们需要再次接受邀请。",
"userRemoveOrgConfirm": "确认删除用户",
"userRemoveOrg": "从组织中删除用户",
"userQuestionOrgRemoveSelf": "你确定要将自己从这个组织中移除吗?",
"userMessageOrgRemoveSelf": "你将立即失去访问权限。管理员稍后可以再次邀请你,但你需要接受新的邀请。",
"userRemoveOrgConfirmSelf": "确认删除我自己",
"userRemoveOrgSelf": "将自己从组织中移除",
"userRemoveOrgSelfWarning": "你将立即失去对此组织的访问权限。",
"userRemoveOrgConfirmPhraseSelf": "从组织中移除我自己",
"users": "用户",
"accessRoleMember": "成员",
"accessRoleOwner": "所有者",
@@ -531,6 +541,11 @@
"emailInvalid": "无效的电子邮件地址",
"inviteValidityDuration": "请选择持续时间",
"accessRoleSelectPlease": "请选择一个角色",
"removeOwnAdminRoleConfirmTitle": "移除你的管理员权限?",
"removeOwnAdminRoleConfirmDescription": "保存后,你将不再拥有该组织的管理员权限。如果需要,其他管理员可以恢复访问。",
"removeOwnAdminRoleConfirmButton": "移除我的管理员访问权限",
"removeOwnAdminRoleConfirmPhrase": "移除我的管理员访问",
"ownerMustRetainAdminRole": "组织所有者必须保留至少一个管理员角色。",
"usernameRequired": "必须输入用户名",
"idpSelectPlease": "请选择身份提供商",
"idpGenericOidc": "通用的 OAuth2/OIDC 提供商。",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "在上面添加多个目标将启用负载平衡。",
"targetsSubmit": "保存目标",
"addTarget": "添加目标",
"proxyMultiSiteRoundRobinNodeHelp": "轮询路由在未连接到相同节点的站点之间将不起作用,但故障转移会生效。",
"targetErrorInvalidIp": "无效的 IP 地址",
"targetErrorInvalidIpDescription": "请输入有效的IP地址或主机名",
"targetErrorInvalidPort": "无效的端口",
@@ -1499,7 +1515,7 @@
"alertingGraphCanvasTitle": "规则流程",
"alertingGraphCanvasDescription": "源、触发器和操作的视觉概况。选择一个节点,在面板上进行编辑。",
"alertingNodeNotConfigured": "尚未配置",
"alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}",
"alertingNodeActionsCount": "{count, plural, other {# 操作}}",
"alertingNodeRoleSource": "来源",
"alertingNodeRoleTrigger": "触发",
"alertingNodeRoleAction": "行为",
@@ -2051,7 +2067,7 @@
"createInternalResourceDialogName": "名称",
"createInternalResourceDialogSite": "站点",
"selectSite": "选择站点...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}",
"noSitesFound": "未找到站点。",
"createInternalResourceDialogProtocol": "协议",
"createInternalResourceDialogTcp": "TCP",
@@ -2652,6 +2668,8 @@
"validPassword": "有效密码",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"view": "查看",
"configManaged": "配置已管理",
"connectedClient": "已连接客户端",
"resourceBlocked": "资源被阻止",
"droppedByRule": "被规则删除",
@@ -2672,7 +2690,7 @@
"logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志",
"requestLogsDescription": "查看此机构资源的详细请求日志",
"requestAnalyticsDescription": "查看此机构资源的详细请求分析",
"logRetentionRequestLabel": "请求日志保留",
"logRetentionRequestLabel": "HTTP 请求日志保留",
"logRetentionRequestDescription": "保留请求日志的时间",
"logRetentionAccessLabel": "访问日志保留",
"logRetentionAccessDescription": "保留访问日志的时间",
@@ -3062,7 +3080,7 @@
"streamingDatadogTitle": "Datadog",
"streamingDatadogDescription": "直接转发事件到您的Datadog 帐户。即将推出。",
"streamingTypePickerDescription": "选择要开始的目标类型。",
"streamingFailedToLoad": "加载目的地失败",
"streamingLastSyncError": "最后一次同步时发生错误",
"streamingUnexpectedError": "发生意外错误.",
"streamingFailedToUpdate": "更新目标失败",
"streamingDeletedSuccess": "目标删除成功",
@@ -3079,7 +3097,34 @@
"S3DestEditTitle": "编辑目的地",
"S3DestAddTitle": "添加 S3 目的地",
"S3DestEditDescription": "更新此 S3 事件流目的地的配置。",
"S3DestAddDescription": "配置新的 S3 终端以接收您的组织事件。",
"S3DestAddDescription": "配置一个新的 Amazon S3或兼容 S3 的)存储桶以接收您的组织事件。",
"s3DestTabSettings": "设置",
"s3DestTabFormat": "格式",
"s3DestNameLabel": "名称",
"s3DestNamePlaceholder": "我的 S3 目的地",
"s3DestAccessKeyIdLabel": "AWS 访问密钥 ID",
"s3DestSecretAccessKeyLabel": "AWS 秘密访问密钥",
"s3DestSecretAccessKeyPlaceholder": "您的 AWS 密钥",
"s3DestRegionLabel": "AWS 地区",
"s3DestBucketLabel": "存储桶名称",
"s3DestPrefixLabel": "密钥前缀(可选)",
"s3DestPrefixDescription": "每个对象密钥前加的可选路径前缀。对象存储在 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}。",
"s3DestEndpointLabel": "自定义端点(可选)",
"s3DestEndpointDescription": "替代 S3 端点用于 MinIO 或 Cloudflare R2 等兼容 S3 的存储。标准 AWS S3 留空。",
"s3DestGzipLabel": "Gzip 压缩",
"s3DestGzipDescription": "使用 gzip 压缩每个上传的对象。减少存储成本和上传大小。",
"s3DestFormatTitle": "文件格式",
"s3DestFormatDescription": "事件在每个上传对象内的序列化方式。",
"s3DestFormatJsonArrayDescription": "每个对象是事件记录的 JSON 数组。兼容大多数分析工具。",
"s3DestFormatNdjsonDescription": "每个对象每行包含一个 JSON 记录(换行分隔的 JSON。兼容 Athena、BigQuery 和 Spark。",
"s3DestFormatCsvTitle": "CSV",
"s3DestFormatCsvDescription": "每个对象是带有标题行的 RFC-4180 CSV 文件。列名来自事件数据字段。",
"s3DestSaveChanges": "保存更改",
"s3DestCreateDestination": "创建目的地",
"s3DestUpdatedSuccess": "目的地更新成功",
"s3DestCreatedSuccess": "目的地创建成功",
"s3DestUpdateFailed": "更新目的地失败",
"s3DestCreateFailed": "创建目的地失败",
"datadogDestEditTitle": "编辑目的地",
"datadogDestAddTitle": "添加 Datadog 目的地",
"datadogDestEditDescription": "更新此 Datadog 事件流目的地的配置。",
@@ -3208,5 +3253,48 @@
"domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。",
"domainPickerWildcardCertWarningLink": "了解更多",
"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

@@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
reactStrictMode: false,
transpilePackages: ["@novnc/novnc"],
eslint: {
ignoreDuringBuilds: true
},

102
package-lock.json generated
View File

@@ -11,11 +11,14 @@
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -44,6 +47,9 @@
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",
@@ -1058,7 +1064,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1460,6 +1465,16 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@devolutions/iron-remote-desktop": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
},
"node_modules/@devolutions/iron-remote-desktop-rdp": {
"version": "0.0.0",
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"integrity": "sha512-qkpqYOMmSU6jIdDKOWpnLL7pb0sA/Y7Yjm4wI0/VbBIRfZPH8WdKxDU5DNp8jFF60l1zbcSJeRIq5yIAvws3Hw=="
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
@@ -2354,7 +2369,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2377,7 +2391,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2400,7 +2413,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2417,7 +2429,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2434,7 +2445,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2451,7 +2461,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2468,7 +2477,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2485,7 +2493,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2502,7 +2509,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2519,7 +2525,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2536,7 +2541,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2553,7 +2557,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2576,7 +2579,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2599,7 +2601,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2622,7 +2623,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2645,7 +2645,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2668,7 +2667,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2691,7 +2689,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2714,7 +2711,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -2734,7 +2730,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2754,7 +2749,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2774,7 +2768,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3034,7 +3027,6 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -3654,6 +3646,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oslojs/asn1": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
@@ -6981,7 +6979,6 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -8442,7 +8439,6 @@
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -8558,7 +8554,6 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -8906,7 +8901,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -9002,7 +8996,6 @@
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -9030,7 +9023,6 @@
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -9056,7 +9048,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -9067,7 +9058,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -9154,7 +9144,8 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
@@ -9228,7 +9219,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -9683,6 +9673,27 @@
"win32"
]
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -9702,7 +9713,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10152,7 +10162,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -10224,7 +10233,6 @@
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -10353,7 +10361,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -11260,7 +11267,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -11701,6 +11707,7 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -12335,7 +12342,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -12421,7 +12427,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -12558,7 +12563,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -12952,7 +12956,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -15370,6 +15373,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -15380,6 +15384,7 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -15468,7 +15473,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.15",
"@swc/helpers": "0.5.15",
@@ -16428,7 +16432,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -16936,7 +16939,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16968,7 +16970,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -17261,7 +17262,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -18723,8 +18723,7 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -19199,7 +19198,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19627,7 +19625,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -19834,7 +19831,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -34,11 +34,14 @@
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -67,6 +70,9 @@
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.15.0",
"better-sqlite3": "11.9.1",

View File

@@ -87,7 +87,7 @@ function createDb() {
export const db = createDb();
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<
Parameters<(typeof db)["transaction"]>[0]
>[0];

View File

@@ -332,6 +332,7 @@ export const connectionAuditLog = pgTable(
clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade"
}),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
@@ -439,6 +440,8 @@ export const eventStreamingDestinations = pgTable(
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: boolean("enabled").notNull().default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: bigint("lastErrorAt", { mode: "number" }), // epoch ms of last error, null if healthy
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
}
@@ -577,6 +580,23 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
destination: varchar("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -624,3 +644,6 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -332,6 +332,7 @@ export const connectionAuditLog = sqliteTable(
clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade"
}),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
@@ -445,6 +446,8 @@ export const eventStreamingDestinations = sqliteTable(
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: integer("lastErrorAt"), // epoch ms of last error, null if healthy
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
}
@@ -585,6 +588,25 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull()
});
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
type: text("type").notNull(), // "ssh", "rdp", "vnc"
destination: text("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -624,3 +646,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

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

View File

@@ -1227,7 +1227,11 @@ async function getDomainId(
return null;
}
const domainSelection = validDomains[0].domains;
// Pick the most specific (longest baseDomain) valid domain so that, e.g.,
// *.test.dev.example.com is assigned to *.dev.example.com rather than *.example.com.
const domainSelection = validDomains.sort(
(a, b) => b.domains.baseDomain.length - a.domains.baseDomain.length
)[0].domains;
const baseDomain = domainSelection.baseDomain;
// Wildcard full-domains are not allowed on namespace (provided/free) domains

View File

@@ -25,9 +25,162 @@ import { tierMatrix } from "./billing/tierMatrix";
export async function calculateUserClientsForOrgs(
userId: string,
trx?: Transaction
trx: Transaction | typeof db = db
): 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
const userOlms = await transaction
.select()
@@ -54,7 +207,9 @@ export async function calculateUserClientsForOrgs(
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.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<
string,
(typeof userOrgRoleRows)[0][]
@@ -64,6 +219,13 @@ export async function calculateUserClientsForOrgs(
list.push(r);
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 (const olm of userOlms) {
@@ -71,10 +233,7 @@ export async function calculateUserClientsForOrgs(
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
const org = await getOrg(orgId);
if (!org) {
logger.warn(
@@ -91,11 +250,7 @@ export async function calculateUserClientsForOrgs(
}
// Get admin role for this org (needed for access grants)
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
@@ -105,64 +260,50 @@ export async function calculateUserClientsForOrgs(
}
// Check if a client already exists for this OLM+user+org combination
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olm.olmId)
)
)
.limit(1);
const existingClient = await getExistingClient(
orgId,
olm.olmId
);
if (existingClient) {
// Ensure admin role has access to the client
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, adminRole.roleId),
eq(
roleClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!existingRoleClient) {
if (!hasRoleAccess) {
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`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
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, userId),
eq(
userClients.clientId,
existingClient.clientId
)
)
)
.limit(1);
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
);
if (!existingUserClient) {
if (!hasUserAccess) {
await transaction.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`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
const exitNodesList = await listExitNodes(orgId);
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
@@ -206,14 +347,11 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await isLicensedOrSubscribed(
userOrg.orgId,
tierMatrix.deviceApprovals
);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
@@ -232,6 +370,10 @@ export async function calculateUserClientsForOrgs(
.insert(clients)
.values(newClientData)
.returning();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request
if (requireApproval) {
@@ -257,12 +399,20 @@ export async function calculateUserClientsForOrgs(
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await transaction.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`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(
userId: string,
trx: Transaction,
trx: Transaction | typeof db,
userOrgIds: string[] = []
): Promise<void> {
// 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";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.18.2";
export const APP_VERSION = "1.18.4";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

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

View File

@@ -24,8 +24,11 @@ export async function getCachedStatusHistory(
return cached;
}
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
// Anchor to UTC midnight so the query window aligns with stable calendar days
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
.select()
@@ -110,11 +113,18 @@ export function computeBuckets(
days: number
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
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[] = [];
let totalDowntime = 0;
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 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[] {
const results: string[] = [];
let entries: fs.Dirent[];
@@ -575,18 +702,16 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
}
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`);
continue;
}
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
if (!cert.certificate || !cert.key) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
);
continue;
}
@@ -598,14 +723,14 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
} catch (err) {
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;
}
if (!certPem.trim() || !keyPem.trim()) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
);
continue;
}
@@ -616,7 +741,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
);
continue;
}
@@ -628,7 +753,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
);
continue;
}
@@ -638,139 +763,40 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
);
continue;
}
// Check if cert already exists in DB
const existing = await db
.select()
.from(certificates)
.where(and(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) {
// logger.debug(
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
// );
continue;
// Collect all domains covered by this cert: main + every SAN.
// Each domain gets its own row in the certificates table so that
// lookups by any hostname on the cert succeed independently.
const allDomains = new Set<string>([mainDomain]);
if (Array.isArray(cert.domain?.sans)) {
for (const san of cert.domain.sans) {
if (typeof san === "string" && san.trim()) {
allDomains.add(san.trim());
}
// 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
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!
logger.debug(
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
);
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"})`
);
// For a brand-new cert, push to any SSL resources that were waiting for it
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
for (const domain of allDomains) {
try {
await storeCertForDomain(
domain,
certPem,
keyPem,
validatedX509
);
} catch (err) {
logger.error(
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
);
}
}
}
}

View File

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

View File

@@ -236,15 +236,43 @@ interface TemplateContext {
}
/**
* Render a body template with {{event}}, {{timestamp}}, {{status}}, and
* {{data}} placeholders, mirroring the logic in HttpLogDestination.
* Render a body template with {{event}}, {{timestamp}}, {{status}}, {{data}},
* and individual data-field placeholders (e.g. {{orgId}}, {{siteId}}, …).
*
* {{data}} is replaced first (as raw JSON) so that any literal "{{…}}"
* strings inside data values are not re-expanded.
* Replacement order:
* 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 {
const rendered = template
.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data))
// Step 1 expand {{data}} first so its contents are already serialised
// 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(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
.replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));

View File

@@ -97,6 +97,13 @@ export class PrivateConfig {
);
}
process.env.BRANDING_HIDE_POWERED_BY =
this.rawPrivateConfig.branding?.hide_powered_by === true ||
this.rawPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.subtitle_text || "";

View File

@@ -46,6 +46,7 @@ export interface ConnectionLogRecord {
orgId: string;
siteId: number;
clientId: number | null;
clientEndpoint: string | null;
userId: string | null;
sourceAddr: string;
destAddr: string;

View File

@@ -30,10 +30,12 @@ import {
LOG_TYPES,
LogEvent,
DestinationFailureState,
HttpConfig
HttpConfig,
S3Config
} from "./types";
import { LogDestinationProvider } from "./providers/LogDestinationProvider";
import { HttpLogDestination } from "./providers/HttpLogDestination";
import { S3LogDestination } from "./providers/S3LogDestination";
import type { EventStreamingDestination } from "@server/db";
// ---------------------------------------------------------------------------
@@ -72,11 +74,11 @@ const MAX_CATCHUP_BATCHES = 20;
* After the last entry the max value is re-used.
*/
const BACKOFF_SCHEDULE_MS = [
60_000, // 1 min (failure 1)
2 * 60_000, // 2 min (failure 2)
5 * 60_000, // 5 min (failure 3)
10 * 60_000, // 10 min (failure 4)
30 * 60_000 // 30 min (failure 5+)
60_000, // 1 min (failure 1)
2 * 60_000, // 2 min (failure 2)
5 * 60_000, // 5 min (failure 3)
10 * 60_000, // 10 min (failure 4)
30 * 60_000 // 30 min (failure 5+)
];
/**
@@ -204,7 +206,10 @@ export class LogStreamingManager {
this.pollTimer = null;
this.runPoll()
.catch((err) =>
logger.error("LogStreamingManager: unexpected poll error", err)
logger.error(
"LogStreamingManager: unexpected poll error",
err
)
)
.finally(() => {
if (this.isRunning) {
@@ -275,10 +280,13 @@ export class LogStreamingManager {
}
// Decrypt and parse config skip destination if either step fails
let configFromDb: HttpConfig;
let configFromDb: unknown;
try {
const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!);
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
const decryptedConfig = decrypt(
dest.config,
config.getRawConfig().server.secret!
);
configFromDb = JSON.parse(decryptedConfig);
} catch (err) {
logger.error(
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
@@ -305,6 +313,7 @@ export class LogStreamingManager {
if (enabledTypes.length === 0) return;
let anyFailure = false;
let firstError: string | null = null;
for (const logType of enabledTypes) {
if (!this.isRunning) break;
@@ -312,6 +321,10 @@ export class LogStreamingManager {
await this.processLogType(dest, provider, logType);
} catch (err) {
anyFailure = true;
if (firstError === null) {
firstError =
err instanceof Error ? err.message : String(err);
}
logger.error(
`LogStreamingManager: failed to process "${logType}" logs ` +
`for destination ${dest.destinationId}`,
@@ -322,6 +335,10 @@ export class LogStreamingManager {
if (anyFailure) {
this.recordFailure(dest.destinationId);
await this.setDestinationError(
dest.destinationId,
firstError ?? "Unknown error"
);
} else {
// Any success resets the failure/back-off state
if (this.failures.has(dest.destinationId)) {
@@ -330,6 +347,7 @@ export class LogStreamingManager {
`LogStreamingManager: destination ${dest.destinationId} recovered`
);
}
await this.clearDestinationError(dest.destinationId);
}
}
@@ -362,7 +380,10 @@ export class LogStreamingManager {
.from(eventStreamingCursors)
.where(
and(
eq(eventStreamingCursors.destinationId, dest.destinationId),
eq(
eventStreamingCursors.destinationId,
dest.destinationId
),
eq(eventStreamingCursors.logType, logType)
)
)
@@ -431,9 +452,7 @@ export class LogStreamingManager {
if (rows.length === 0) break;
const events = rows.map((row) =>
this.rowToLogEvent(logType, row)
);
const events = rows.map((row) => this.rowToLogEvent(logType, row));
// Throws on failure caught by the caller which applies back-off
await provider.send(events);
@@ -677,8 +696,7 @@ export class LogStreamingManager {
break;
}
const orgId =
typeof row.orgId === "string" ? row.orgId : "";
const orgId = typeof row.orgId === "string" ? row.orgId : "";
return {
id: row.id,
@@ -708,6 +726,8 @@ export class LogStreamingManager {
switch (type) {
case "http":
return new HttpLogDestination(config as HttpConfig);
case "s3":
return new S3LogDestination(config as S3Config);
// Future providers:
// case "datadog": return new DatadogLogDestination(config as DatadogConfig);
default:
@@ -749,6 +769,45 @@ export class LogStreamingManager {
// DB helpers
// -------------------------------------------------------------------------
private async setDestinationError(
destinationId: number,
errorMessage: string
): Promise<void> {
// Truncate to 1000 chars so it fits comfortably in the text column.
const truncated = errorMessage.slice(0, 1000);
try {
await db
.update(eventStreamingDestinations)
.set({ lastError: truncated, lastErrorAt: Date.now() })
.where(
eq(eventStreamingDestinations.destinationId, destinationId)
);
} catch (err) {
logger.warn(
`LogStreamingManager: could not persist error status for destination ${destinationId}`,
err
);
}
}
private async clearDestinationError(destinationId: number): Promise<void> {
try {
// Only update if there is actually an error stored, to avoid
// unnecessary writes on every successful poll cycle.
await db
.update(eventStreamingDestinations)
.set({ lastError: null, lastErrorAt: null })
.where(
eq(eventStreamingDestinations.destinationId, destinationId)
);
} catch (err) {
logger.warn(
`LogStreamingManager: could not clear error status for destination ${destinationId}`,
err
);
}
}
private async loadEnabledDestinations(): Promise<
EventStreamingDestination[]
> {

View File

@@ -0,0 +1,279 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { gzip as gzipCallback } from "zlib";
import { promisify } from "util";
import { randomUUID } from "crypto";
import logger from "@server/logger";
import { LogEvent, S3Config, S3PayloadFormat } from "../types";
import { LogDestinationProvider } from "./LogDestinationProvider";
const gzipAsync = promisify(gzipCallback);
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Maximum time (ms) to wait for a single S3 PutObject response. */
const REQUEST_TIMEOUT_MS = 60_000;
/** Default payload format when none is specified in the config. */
const DEFAULT_FORMAT: S3PayloadFormat = "json_array";
// ---------------------------------------------------------------------------
// S3LogDestination
// ---------------------------------------------------------------------------
/**
* Forwards a batch of log events to an S3-compatible object store by
* uploading a single object per `send()` call.
*
* **Object key layout**
* ```
* {prefix}/{logType}/{YYYY}/{MM}/{DD}/{HH}-{mm}-{ss}-{uuid}.{ext}[.gz]
* ```
* - `prefix` from `config.prefix` (default: empty key starts at logType)
* - `logType` one of "request", "action", "access", "connection"
* - Date components are derived from the upload time (UTC)
* - `ext` `json` | `ndjson` | `csv`
* - `.gz` appended when `config.gzip` is true
*
* **Payload formats** (controlled by `config.format`):
* - `json_array` (default) body is a JSON array of event objects.
* - `ndjson` one JSON object per line (newline-delimited).
* - `csv` RFC-4180 CSV with a header row; columns are the
* union of all field names in the batch's event data.
*
* **Compression**: when `config.gzip` is `true` the body is gzip-compressed
* before upload and `Content-Encoding: gzip` is set on the object.
*
* **Custom endpoint**: set `config.endpoint` to target any S3-compatible
* storage service (e.g. MinIO, Cloudflare R2).
*/
export class S3LogDestination implements LogDestinationProvider {
readonly type = "s3";
private readonly config: S3Config;
constructor(config: S3Config) {
this.config = config;
}
// -----------------------------------------------------------------------
// LogDestinationProvider implementation
// -----------------------------------------------------------------------
async send(events: LogEvent[]): Promise<void> {
if (events.length === 0) return;
const format = this.config.format ?? DEFAULT_FORMAT;
const useGzip = this.config.gzip ?? false;
const logType = events[0].logType;
const rawBody = this.serialize(events, format);
const bodyBuffer = Buffer.from(rawBody, "utf-8");
let uploadBody: Buffer;
let contentEncoding: string | undefined;
if (useGzip) {
uploadBody = (await gzipAsync(bodyBuffer)) as Buffer;
contentEncoding = "gzip";
} else {
uploadBody = bodyBuffer;
}
const key = this.buildObjectKey(logType, format, useGzip);
const contentType = this.contentType(format);
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
region: this.config.region,
credentials: {
accessKeyId: this.config.accessKeyId,
secretAccessKey: this.config.secretAccessKey
},
requestHandler: {
requestTimeout: REQUEST_TIMEOUT_MS
}
};
if (this.config.endpoint?.trim()) {
clientConfig.endpoint = this.config.endpoint.trim();
}
const client = new S3Client(clientConfig);
try {
await client.send(
new PutObjectCommand({
Bucket: this.config.bucket,
Key: key,
Body: uploadBody,
ContentType: contentType,
...(contentEncoding
? { ContentEncoding: contentEncoding }
: {})
})
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`S3LogDestination: failed to upload object "${key}" ` +
`to bucket "${this.config.bucket}" ${msg}`
);
}
}
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
/**
* Construct a unique S3 object key for the given log type and format.
* Keys are partitioned by logType and date so they can be queried or
* lifecycle-managed independently.
*/
private buildObjectKey(
logType: string,
format: S3PayloadFormat,
gzip: boolean
): string {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
const day = String(now.getUTCDate()).padStart(2, "0");
const hh = String(now.getUTCHours()).padStart(2, "0");
const mm = String(now.getUTCMinutes()).padStart(2, "0");
const ss = String(now.getUTCSeconds()).padStart(2, "0");
const uid = randomUUID();
const ext =
format === "csv" ? "csv" : format === "ndjson" ? "ndjson" : "json";
const fileName = `${hh}-${mm}-${ss}-${uid}.${ext}${gzip ? ".gz" : ""}`;
const rawPrefix = (this.config.prefix ?? "").trim().replace(/\/+$/, "");
const parts = [
rawPrefix,
logType,
`${year}/${month}/${day}`,
fileName
].filter((p) => p !== "");
return parts.join("/");
}
private contentType(format: S3PayloadFormat): string {
switch (format) {
case "csv":
return "text/csv; charset=utf-8";
case "ndjson":
return "application/x-ndjson";
default:
return "application/json";
}
}
private serialize(events: LogEvent[], format: S3PayloadFormat): string {
switch (format) {
case "json_array":
return JSON.stringify(events.map(toPayload));
case "ndjson":
return events
.map((e) => JSON.stringify(toPayload(e)))
.join("\n");
case "csv":
return toCsv(events);
}
}
}
// ---------------------------------------------------------------------------
// Payload helpers
// ---------------------------------------------------------------------------
function toPayload(event: LogEvent): unknown {
return {
event: event.logType,
timestamp: new Date(event.timestamp * 1000).toISOString(),
data: event.data
};
}
/**
* Convert a batch of events to RFC-4180 CSV.
*
* The column set is the union of `event`, `timestamp`, and all keys present in
* `event.data` across the batch, preserving insertion order. Values that
* contain commas, double-quotes, or newlines are quoted and escaped.
*/
function toCsv(events: LogEvent[]): string {
if (events.length === 0) return "";
// Collect all unique data keys in stable order
const keySet = new LinkedSet<string>();
keySet.add("event");
keySet.add("timestamp");
for (const e of events) {
for (const k of Object.keys(e.data)) {
keySet.add(k);
}
}
const headers = keySet.toArray();
const rows: string[] = [headers.map(csvEscape).join(",")];
for (const e of events) {
const flat: Record<string, unknown> = {
event: e.logType,
timestamp: new Date(e.timestamp * 1000).toISOString(),
...e.data
};
rows.push(
headers.map((h) => csvEscape(flattenValue(flat[h]))).join(",")
);
}
return rows.join("\n");
}
/** Flatten a value to a plain string suitable for a CSV cell. */
function flattenValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
/** RFC-4180 CSV escaping. */
function csvEscape(value: string): string {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
// ---------------------------------------------------------------------------
// Minimal ordered set (preserves insertion order, deduplicates)
// ---------------------------------------------------------------------------
class LinkedSet<T> {
private readonly map = new Map<T, true>();
add(value: T): void {
this.map.set(value, true);
}
toArray(): T[] {
return Array.from(this.map.keys());
}
}

View File

@@ -107,6 +107,40 @@ export interface HttpConfig {
bodyTemplate?: string;
}
// ---------------------------------------------------------------------------
// S3 destination configuration
// ---------------------------------------------------------------------------
/**
* Controls how the batch of events is serialised into each S3 object.
*
* - `json_array` `[{…}, {…}]` default; each object is a JSON array.
* - `ndjson` `{…}\n{…}` newline-delimited JSON, one object per line.
* - `csv` RFC-4180 CSV with a header row derived from the event fields.
*/
export type S3PayloadFormat = "json_array" | "ndjson" | "csv";
export interface S3Config {
/** Human-readable label for the destination */
name: string;
/** AWS Access Key ID */
accessKeyId: string;
/** AWS Secret Access Key */
secretAccessKey: string;
/** AWS region (e.g. "us-east-1") */
region: string;
/** Target S3 bucket name */
bucket: string;
/** Optional key prefix appended before the auto-generated path */
prefix?: string;
/** Override the S3 endpoint for S3-compatible storage (e.g. MinIO, R2) */
endpoint?: string;
/** How events are serialised into each object. Defaults to "json_array". */
format: S3PayloadFormat;
/** Whether to gzip-compress the object before upload. */
gzip: boolean;
}
// ---------------------------------------------------------------------------
// Per-destination per-log-type cursor (reflects the DB table)
// ---------------------------------------------------------------------------

View File

@@ -141,6 +141,7 @@ export const privateConfigSchema = z
)
.optional(),
hide_auth_layout_footer: z.boolean().optional().default(false),
hide_powered_by: z.boolean().optional(),
login_page: z
.object({
subtitle_text: z.string().optional()

View File

@@ -124,15 +124,11 @@ function getWhere(data: Q) {
data.clientId
? eq(connectionAuditLog.clientId, data.clientId)
: undefined,
data.siteId
? eq(connectionAuditLog.siteId, data.siteId)
: undefined,
data.siteId ? eq(connectionAuditLog.siteId, data.siteId) : undefined,
data.siteResourceId
? eq(connectionAuditLog.siteResourceId, data.siteResourceId)
: undefined,
data.userId
? eq(connectionAuditLog.userId, data.userId)
: undefined
data.userId ? eq(connectionAuditLog.userId, data.userId) : undefined
);
}
@@ -144,6 +140,7 @@ export function queryConnection(data: Q) {
orgId: connectionAuditLog.orgId,
siteId: connectionAuditLog.siteId,
clientId: connectionAuditLog.clientId,
clientEndpoint: connectionAuditLog.clientEndpoint,
userId: connectionAuditLog.userId,
sourceAddr: connectionAuditLog.sourceAddr,
destAddr: connectionAuditLog.destAddr,
@@ -203,10 +200,7 @@ async function enrichWithDetails(
];
// Fetch resource details from main database
const resourceMap = new Map<
number,
{ name: string; niceId: string }
>();
const resourceMap = new Map<number, { name: string; niceId: string }>();
if (siteResourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
@@ -268,10 +262,7 @@ async function enrichWithDetails(
}
// Fetch user details from main database
const userMap = new Map<
string,
{ email: string | null }
>();
const userMap = new Map<string, { email: string | null }>();
if (userIds.length > 0) {
const userDetails = await primaryDb
.select({
@@ -290,29 +281,25 @@ async function enrichWithDetails(
return logs.map((log) => ({
...log,
resourceName: log.siteResourceId
? resourceMap.get(log.siteResourceId)?.name ?? null
? (resourceMap.get(log.siteResourceId)?.name ?? null)
: null,
resourceNiceId: log.siteResourceId
? resourceMap.get(log.siteResourceId)?.niceId ?? null
: null,
siteName: log.siteId
? siteMap.get(log.siteId)?.name ?? null
? (resourceMap.get(log.siteResourceId)?.niceId ?? null)
: null,
siteName: log.siteId ? (siteMap.get(log.siteId)?.name ?? null) : null,
siteNiceId: log.siteId
? siteMap.get(log.siteId)?.niceId ?? null
? (siteMap.get(log.siteId)?.niceId ?? null)
: null,
clientName: log.clientId
? clientMap.get(log.clientId)?.name ?? null
? (clientMap.get(log.clientId)?.name ?? null)
: null,
clientNiceId: log.clientId
? clientMap.get(log.clientId)?.niceId ?? null
? (clientMap.get(log.clientId)?.niceId ?? null)
: null,
clientType: log.clientId
? clientMap.get(log.clientId)?.type ?? null
? (clientMap.get(log.clientId)?.type ?? null)
: null,
userEmail: log.userId
? userMap.get(log.userId)?.email ?? null
: null
userEmail: log.userId ? (userMap.get(log.userId)?.email ?? null) : null
}));
}
@@ -521,4 +508,4 @@ export async function queryConnectionAuditLogs(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -51,6 +51,8 @@ export type ListEventStreamingDestinationsResponse = {
type: string;
config: string;
enabled: boolean;
lastError: string | null;
lastErrorAt: number | null;
createdAt: number;
updatedAt: number;
sendConnectionLogs: boolean;
@@ -79,7 +81,8 @@ async function query(orgId: string, limit: number, offset: number) {
registry.registerPath({
method: "get",
path: "/org/{orgId}/event-streaming-destination",
description: "List all event streaming destinations for a specific organization.",
description:
"List all event streaming destinations for a specific organization.",
tags: [OpenAPITags.Org],
request: {
query: querySchema,

View File

@@ -11,7 +11,7 @@
* This file is not licensed under the AGPLv3.
*/
import { db } from "@server/db";
import { clientSitesAssociationsCache, db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, clients, orgs } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
@@ -146,7 +146,11 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
// each unique sourceAddr + the org's CIDR suffix and do a targeted IN query.
const ipToClient = new Map<
string,
{ clientId: number; userId: string | null }
{
clientId: number;
userId: string | null;
clientEndpoint: string | null;
}
>();
if (cidrSuffix) {
@@ -172,9 +176,21 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
.select({
clientId: clients.clientId,
userId: clients.userId,
subnet: clients.subnet
subnet: clients.subnet,
clientEndpoint: clientSitesAssociationsCache.endpoint
})
.from(clients)
.leftJoin(
// this should be one to one
clientSitesAssociationsCache,
and(
eq(
clients.clientId,
clientSitesAssociationsCache.clientId
),
eq(clientSitesAssociationsCache.siteId, newt.siteId)
)
)
.where(
and(
eq(clients.orgId, orgId),
@@ -189,7 +205,8 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
);
ipToClient.set(ip, {
clientId: c.clientId,
userId: c.userId
userId: c.userId,
clientEndpoint: c.clientEndpoint
});
}
}
@@ -234,6 +251,7 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
orgId,
siteId: newt.siteId,
clientId: clientInfo?.clientId ?? null,
clientEndpoint: clientInfo?.clientEndpoint ?? null,
userId: clientInfo?.userId ?? null,
sourceAddr: session.sourceAddr,
destAddr: session.destAddr,

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
@@ -98,15 +98,6 @@ export async function addUserRole(
);
}
if (existingUser[0].isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the role of the owner of the organization"
)
);
}
const roleExists = await db
.select()
.from(roles)
@@ -122,8 +113,12 @@ export async function addUserRole(
);
}
let newUserRole: { userId: string; orgId: string; roleId: number } | null =
null;
let newUserRole: {
userId: string;
orgId: string;
roleId: number;
} | null = null;
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => {
const inserted = await trx
.insert(userOrgRoles)
@@ -149,11 +144,19 @@ export async function addUserRole(
)
);
for (const orgClient of orgClients) {
await rebuildClientAssociationsFromClient(orgClient, trx);
}
orgClientsToRebuild = orgClients;
});
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, {
data: newUserRole ?? { userId, orgId: role.orgId, roleId },
success: true,

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
@@ -98,11 +98,11 @@ export async function removeUserRole(
);
}
if (existingUser.isOwner) {
if (existingUser.isOwner && role.isAdmin === true) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization"
"Cannot remove the administrator role from the organization owner"
)
);
}
@@ -129,6 +129,7 @@ export async function removeUserRole(
}
}
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => {
await trx
.delete(userOrgRoles)
@@ -150,11 +151,19 @@ export async function removeUserRole(
)
);
for (const orgClient of orgClients) {
await rebuildClientAssociationsFromClient(orgClient, trx);
}
orgClientsToRebuild = orgClients;
});
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, {
data: { userId, orgId: role.orgId, roleId },
success: true,

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express";
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 { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
@@ -87,17 +87,8 @@ export async function setUserOrgRoles(
);
}
if (existingUser.isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization"
)
);
}
const orgRoles = await db
.select({ roleId: roles.roleId })
.select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
.from(roles)
.where(
and(
@@ -115,6 +106,19 @@ export async function setUserOrgRoles(
);
}
if (existingUser.isOwner) {
const hasAdminRole = orgRoles.some((r) => r.isAdmin === true);
if (!hasAdminRole) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"The organization owner must retain an administrator role"
)
);
}
}
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => {
await trx
.delete(userOrgRoles)
@@ -142,11 +146,19 @@ export async function setUserOrgRoles(
and(eq(clients.userId, userId), eq(clients.orgId, orgId))
);
for (const orgClient of orgClients) {
await rebuildClientAssociationsFromClient(orgClient, trx);
}
orgClientsToRebuild = orgClients;
});
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, {
data: { userId, orgId, roleIds: uniqueRoleIds },
success: true,

View File

@@ -100,6 +100,7 @@ export type QueryConnectionAuditLogResponse = {
orgId: string | null;
siteId: number | null;
clientId: number | null;
clientEndpoint: string | null;
userId: string | null;
sourceAddr: string;
destAddr: string;

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -218,13 +218,18 @@ export async function deleteMyAccount(
await db.transaction(async (trx) => {
await trx.delete(users).where(eq(users.userId, userId));
await calculateUserClientsForOrgs(userId, trx);
// loop through the other orgs and decrement the count
for (const userOrg of otherOrgsTheUserWasIn) {
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 {
await invalidateSession(session.sessionId);
} catch (error) {

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, primaryDb } from "@server/db";
import {
roles,
Client,
@@ -92,7 +92,10 @@ export async function createClient(
const { orgId } = parsedParams.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -198,7 +201,10 @@ export async function createClient(
if (!randomExitNode) {
return next(
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`)
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,
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, {
data: newClient,
success: true,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, primaryDb } from "@server/db";
import {
roles,
Client,
@@ -237,10 +237,18 @@ export async function createUserClient(
userId,
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, {
data: newClient,
success: true,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
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 { eq } from "drizzle-orm";
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) => {
// Then delete the client itself
const [deletedClient] = await trx
[deletedClient] = await trx
.delete(clients)
.where(eq(clients.clientId, clientId))
.returning();
const [olm] = await trx
[olm] = await trx
.select()
.from(olms)
.where(eq(olms.clientId, clientId))
@@ -88,14 +91,29 @@ export async function deleteClient(
if (!client.userId && 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, {
data: null,
success: true,

View File

@@ -512,10 +512,9 @@ export async function validateOidcCallback(
const orgUserCounts: { orgId: string; userCount: number }[] = [];
let userId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
@@ -645,8 +644,15 @@ export async function validateOidcCallback(
userCount: userCount.length
});
}
});
db.transaction(async (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) {

View File

@@ -1,4 +1,6 @@
import {
browserGatewayTarget,
BrowserGatewayTarget,
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
@@ -310,3 +312,12 @@ export async function buildTargetConfigurationForNewtClient(
udpTargets
};
}
export async function buildBrowserGatewayTargetConfigurationForNewtClient(
siteId: number
): Promise<BrowserGatewayTarget[]> {
return await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
}

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import {
buildBrowserGatewayTargetConfigurationForNewtClient,
buildClientConfigurationForNewtClient,
buildTargetConfigurationForNewtClient
} from "./buildConfiguration";
@@ -12,6 +13,9 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
const { tcpTargets, udpTargets, validHealthCheckTargets } =
await buildTargetConfigurationForNewtClient(site.siteId);
const browserGatewayTargets =
await buildBrowserGatewayTargetConfigurationForNewtClient(site.siteId);
let exitNode: ExitNode | undefined;
if (site.exitNodeId) {
[exitNode] = await db
@@ -36,7 +40,15 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
},
healthCheckTargets: validHealthCheckTargets,
peers: peers,
clientTargets: targets
clientTargets: targets,
browserGatewayTargets: browserGatewayTargets.map((t) => ({
id: t.browserGatewayTargetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort
}))
}
},
{

View File

@@ -1,4 +1,4 @@
import { Target, TargetHealthCheck } from "@server/db";
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks";
@@ -239,3 +239,48 @@ export async function removeTargets(
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function sendBrowserGatewayTargets(
newtId: string,
targets: BrowserGatewayTarget[],
version?: string | null
) {
if (targets.length === 0) return;
const payload = targets.map((t) => ({
id: t.browserGatewayTargetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort
}));
await sendToClient(
newtId,
{
type: "newt/browsergateway/add",
data: {
targets: payload
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeBrowserGatewayTarget(
newtId: string,
browserGatewayTargetId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: "newt/browsergateway/remove",
data: {
ids: [browserGatewayTargetId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}

View File

@@ -1,5 +1,5 @@
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 { z } from "zod";
import createHttpError from "http-errors";
@@ -81,16 +81,19 @@ export async function createUserOlm(
const secretHash = await hashPassword(secret);
await db.transaction(async (trx) => {
await trx.insert(olms).values({
olmId: olmId,
userId,
name,
secretHash,
dateCreated: moment().toISOString()
});
await db.insert(olms).values({
olmId: olmId,
userId,
name,
secretHash,
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, {

View File

@@ -1,5 +1,5 @@
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 { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
@@ -49,6 +49,7 @@ export async function deleteUserOlm(
const { olmId } = parsedParams.data;
let deletedClient: Client | undefined;
// Delete associated clients and the OLM in a transaction
await db.transaction(async (trx) => {
// Find all clients associated with this OLM
@@ -57,7 +58,6 @@ export async function deleteUserOlm(
.from(clients)
.where(eq(clients.olmId, olmId));
let deletedClient: Client | null = null;
// Delete all associated clients
if (associatedClients.length > 0) {
[deletedClient] = await trx
@@ -67,23 +67,28 @@ export async function deleteUserOlm(
}
// Finally, delete the OLM itself
const [olm] = await trx
.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
}
}
await trx.delete(olms).where(eq(olms.olmId, olmId)).returning();
});
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, {
data: null,
success: true,

View File

@@ -22,14 +22,14 @@ import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
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 olm = c as Olm;
const now = Math.floor(Date.now() / 1000);
if (!olm) {
logger.warn("Olm not found");
logger.warn("[handleOlmRegisterMessage] Olm not found");
return;
}
@@ -46,16 +46,19 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} = message.data;
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);
return;
}
logger.debug("Handling fingerprint insertion for olm register...", {
olmId: olm.olmId,
fingerprint,
postures
});
logger.debug(
"[handleOlmRegisterMessage] Handling fingerprint insertion for olm register...",
{
olmId: olm.olmId,
fingerprint,
postures
}
);
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
@@ -85,14 +88,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.limit(1);
if (!client) {
logger.warn("Client ID not found");
logger.warn("[handleOlmRegisterMessage] Client not found", {
clientId: olm.clientId
});
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
return;
}
if (client.blocked) {
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);
return;
@@ -100,7 +106,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (client.approvalState == "pending") {
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);
return;
@@ -128,14 +135,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.limit(1);
if (!org) {
logger.warn("Org not found");
logger.warn("[handleOlmRegisterMessage] Org not found", {
orgId: client.orgId
});
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
return;
}
if (orgId) {
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);
return;
}
@@ -143,12 +154,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
const { session: userSession, user } =
await validateSessionToken(userToken);
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);
return;
}
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);
return;
}
@@ -163,11 +180,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
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) {
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);
return;
@@ -175,7 +196,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (policyCheck.policies?.passwordAge?.compliant === false) {
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(
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
@@ -186,7 +208,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
policyCheck.policies?.maxSessionLength?.compliant === false
) {
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(
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
@@ -195,7 +218,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
} else if (policyCheck.policies?.requiredTwoFactor === false) {
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(
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
@@ -204,7 +228,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
} else if (!policyCheck.allowed) {
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);
return;
@@ -226,29 +251,39 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
// 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;
if (sitesCount > 250 && build == "saas") {
// THIS IS THE MAX ON THE BUSINESS TIER
// 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
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;
}
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) {
logger.warn("Public key not provided");
logger.warn("[handleOlmRegisterMessage] Public key not provided", {
orgId: client.orgId
});
return;
}
if (client.pubKey !== publicKey || client.archived) {
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
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 ???
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
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;
}
// 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(
client,
publicKey,

View File

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

View File

@@ -5,7 +5,8 @@ import {
clients,
clientSiteResources,
siteResources,
apiKeyOrg
apiKeyOrg,
primaryDb
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -220,8 +221,12 @@ export async function batchAddClientToSiteResources(
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, {

View File

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

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
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 response from "@server/lib/response";
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
// replica-lag issues while still allowing the HTTP response to return
// early.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
trx
);
}).catch((err) => {
rebuildClientAssociationsFromSiteResource(
removedSiteResource,
primaryDb
).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err

View File

@@ -104,6 +104,17 @@ const updateSiteResourceSchema = z
data.alias.trim() !== "";
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;
},
@@ -112,21 +123,6 @@ const updateSiteResourceSchema = z
"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(
(data) => {
if (data.mode !== "http") return true;

View File

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

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
@@ -88,11 +88,11 @@ export async function addUserRoleLegacy(
);
}
if (existingUser.isOwner) {
if (existingUser.isOwner && role.isAdmin !== true) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the role of the owner of the organization"
"The organization owner must retain an administrator role"
)
);
}
@@ -112,6 +112,8 @@ export async function addUserRoleLegacy(
);
}
let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => {
await trx
.delete(userOrgRoles)
@@ -138,11 +140,19 @@ export async function addUserRoleLegacy(
)
);
for (const orgClient of orgClients) {
await rebuildClientAssociationsFromClient(orgClient, trx);
}
orgClientsToRebuild = orgClients;
});
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, {
data: { ...existingUser, roleId },
success: true,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, primaryDb } from "@server/db";
import { users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -53,8 +53,12 @@ export async function adminRemoveUser(
await db.transaction(async (trx) => {
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, {

View File

@@ -6,7 +6,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
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 { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app";
@@ -34,8 +34,7 @@ const bodySchema = z
roleId: z.number().int().positive().optional()
})
.refine(
(d) =>
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
(d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] }
)
.transform((data) => ({
@@ -100,8 +99,14 @@ export async function createOrgUser(
}
const { orgId } = parsedParams.data;
const { username, email, name, type, idpId, roleIds: uniqueRoleIds } =
parsedBody.data;
const {
username,
email,
name,
type,
idpId,
roleIds: uniqueRoleIds
} = parsedBody.data;
if (build == "saas") {
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) => {
const [existingUser] = await trx
.select()
@@ -270,7 +276,7 @@ export async function createOrgUser(
{
orgId,
userId: existingUser.userId,
autoProvisioned: false,
autoProvisioned: false
},
uniqueRoleIds,
trx
@@ -292,20 +298,30 @@ export async function createOrgUser(
})
.returning();
await assignUserToOrg(
org,
{
orgId,
userId: newUser.userId,
autoProvisioned: false,
},
uniqueRoleIds,
trx
);
await assignUserToOrg(
org,
{
orgId,
userId: newUser.userId,
autoProvisioned: false
},
uniqueRoleIds,
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 {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required")

View File

@@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) {
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
);
const isAdmin = roleRows.some((r) => r.isAdmin);
@@ -61,7 +58,8 @@ export async function queryUser(orgId: string, userId: string) {
roleIds: roleRows.map((r) => r.roleId),
roles: roleRows.map((r) => ({
roleId: r.roleId,
name: r.roleName ?? ""
name: r.roleName ?? "",
isAdmin: r.isAdmin === true
}))
};
}

View File

@@ -7,7 +7,8 @@ import {
siteResources,
sites,
UserOrg,
userSiteResources
userSiteResources,
primaryDb
} from "@server/db";
import { userOrgs, userResources, users, userSites } from "@server/db";
import { and, count, eq, exists, inArray } from "drizzle-orm";
@@ -91,25 +92,12 @@ export async function removeUserOrg(
await db.transaction(async (trx) => {
await removeUserFromOrg(org, userId, trx);
});
// if (build === "saas") {
// const [rootUser] = await trx
// .select()
// .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);
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
logger.error(
`Failed to calculate user clients after removing user ${userId} from org ${orgId}: ${e}`
);
});
return response(res, {

View File

@@ -24,6 +24,7 @@ import m15 from "./scriptsPg/1.16.0";
import m16 from "./scriptsPg/1.17.0";
import m17 from "./scriptsPg/1.18.0";
import m18 from "./scriptsPg/1.18.3";
import m19 from "./scriptsPg/1.18.4";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -47,7 +48,8 @@ const migrations = [
{ version: "1.16.0", run: m15 },
{ version: "1.17.0", run: m16 },
{ version: "1.18.0", run: m17 },
{ version: "1.18.3", run: m18 }
{ version: "1.18.3", run: m18 },
{ version: "1.18.4", run: m19 }
// Add new migrations here as they are created
] as {
version: string;

View File

@@ -42,6 +42,7 @@ import m36 from "./scriptsSqlite/1.16.0";
import m37 from "./scriptsSqlite/1.17.0";
import m38 from "./scriptsSqlite/1.18.0";
import m39 from "./scriptsSqlite/1.18.3";
import m40 from "./scriptsSqlite/1.18.4";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -81,7 +82,8 @@ const migrations = [
{ version: "1.16.0", run: m36 },
{ version: "1.17.0", run: m37 },
{ version: "1.18.0", run: m38 },
{ version: "1.18.3", run: m39 }
{ version: "1.18.3", run: m39 },
{ version: "1.18.4", run: m40 }
// Add new migrations here as they are created
] as const;

View File

@@ -44,7 +44,7 @@ export default async function migration() {
await db.execute(sql`BEGIN`);
await db.execute(sql`
CREATE TABLE "trialNotifications" (
CREATE TABLE IF NOT EXISTS "trialNotifications" (
"notificationId" serial PRIMARY KEY NOT NULL,
"subscriptionId" varchar(255) NOT NULL,
"notificationType" varchar(50) NOT NULL,
@@ -52,10 +52,6 @@ export default async function migration() {
);
`);
await db.execute(sql`
ALTER TABLE "trialNotifications" ADD CONSTRAINT "trialNotifications_subscriptionId_subscriptions_subscriptionId_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."subscriptions"("subscriptionId") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
@@ -77,7 +73,7 @@ export default async function migration() {
}
console.log(
`Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs`
`Updated names for ${existingHealthChecks.length} existing targetHealthCheck row(s)`
);
} catch (e) {
console.error("Error while migrating targetHealthCheck rows:", e);

View File

@@ -0,0 +1,34 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.18.4";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
ALTER TABLE "connectionAuditLog" ADD COLUMN "clientEndpoint" text;
`);
await db.execute(sql`
ALTER TABLE "eventStreamingDestinations" ADD COLUMN "lastError" text;
`);
await db.execute(sql`
ALTER TABLE "eventStreamingDestinations" ADD COLUMN "lastErrorAt" bigint;
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -16,7 +16,7 @@ export default async function migration() {
db.transaction(() => {
db.prepare(
`
CREATE TABLE 'trialNotifications' (
CREATE TABLE IF NOT EXISTS 'trialNotifications' (
'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'subscriptionId' text NOT NULL,
'notificationType' text NOT NULL,

View File

@@ -0,0 +1,43 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.18.4";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.prepare(
`
ALTER TABLE 'connectionAuditLog' ADD 'clientEndpoint' text;
`
).run();
db.prepare(
`
ALTER TABLE 'eventStreamingDestinations' ADD 'lastError' text;
`
).run();
db.prepare(
`
ALTER TABLE 'eventStreamingDestinations' ADD 'lastErrorAt' integer;
`
).run();
})();
db.pragma("foreign_keys = ON");
console.log("Migrated database");
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -175,26 +175,6 @@ export default function GeneralPage() {
}, [variant]);
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 (
availableRoles: { roleId: number; name: string }[]
) => {
@@ -520,6 +500,7 @@ export default function GeneralPage() {
onAutoProvisionChange={(checked) => {
form.setValue("autoProvision", checked);
}}
orgId={orgId as string}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,42 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
FormLabel
} from "@app/components/ui/form";
import { Checkbox } from "@app/components/ui/checkbox";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { useUserContext } from "@app/hooks/useUserContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
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({
username: z.string(),
@@ -46,25 +44,21 @@ const accessControlsFormSchema = z.object({
roles: z.array(
z.object({
id: z.string(),
text: z.string()
text: z.string(),
isAdmin: z.boolean().optional()
})
)
});
export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
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 { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac);
@@ -82,7 +76,8 @@ export default function AccessControlsPage() {
autoProvisioned: user.autoProvisioned || false,
roles: (user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
text: r.name,
isAdmin: r.isAdmin === true
}))
}
});
@@ -94,47 +89,25 @@ export default function AccessControlsPage() {
"roles",
(user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
text: r.name,
isAdmin: r.isAdmin === true
}))
);
}, [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);
}, []);
const allRoleOptions = roles.map((role) => ({
id: role.roleId.toString(),
text: role.name
}));
}, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]);
const paywallMessage =
build === "saas"
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice");
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
const [isSaving, setIsSaving] = useState(false);
const [confirmRemoveOwnAdminOpen, setConfirmRemoveOwnAdminOpen] =
useState(false);
async function executeSave() {
const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
@@ -144,7 +117,7 @@ export default function AccessControlsPage() {
return;
}
setLoading(true);
setIsSaving(true);
try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser
@@ -164,7 +137,8 @@ export default function AccessControlsPage() {
roleIds,
roles: values.roles.map((r) => ({
roleId: parseInt(r.id, 10),
name: r.text
name: r.text,
isAdmin: r.isAdmin === true
})),
autoProvisioned: values.autoProvisioned
});
@@ -183,12 +157,61 @@ export default function AccessControlsPage() {
t("accessRoleErrorAddDescription")
)
});
} finally {
setIsSaving(false);
}
setLoading(false);
}
async function handleAccessControlsSubmit(e: React.FormEvent) {
e.preventDefault();
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
const willHaveAdminRole = values.roles.some(
(r) => r.isAdmin === true
);
const isRemovingOwnAdmin =
sessionUser.userId === user.userId &&
user.isAdmin &&
!willHaveAdminRole;
if (isRemovingOwnAdmin) {
setConfirmRemoveOwnAdminOpen(true);
return;
}
await executeSave();
}
return (
<SettingsContainer>
<ConfirmDeleteDialog
open={confirmRemoveOwnAdminOpen}
setOpen={setConfirmRemoveOwnAdminOpen}
title={t("removeOwnAdminRoleConfirmTitle")}
dialog={
<div className="space-y-2">
<p>{t("removeOwnAdminRoleConfirmDescription")}</p>
</div>
}
buttonText={t("removeOwnAdminRoleConfirmButton")}
string={t("removeOwnAdminRoleConfirmPhrase")}
onConfirm={executeSave}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -203,7 +226,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={(e) => void handleAccessControlsSubmit(e)}
className="space-y-4"
id="access-controls-form"
>
@@ -226,9 +249,7 @@ export default function AccessControlsPage() {
<OrgRolesTagField
form={form}
name="roles"
label={t("roles")}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
orgId={orgId as string}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
@@ -236,9 +257,6 @@ export default function AccessControlsPage() {
showMultiRolePaywallMessage
}
paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/>
{user.idpAutoProvision && (
@@ -277,8 +295,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter>
<Button
type="submit"
loading={loading}
disabled={loading}
loading={isSaving}
disabled={isSaving}
form="access-controls-form"
>
{t("accessControlsSubmit")}

View File

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

View File

@@ -3,10 +3,12 @@
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { defaultFormValues } from "@app/lib/alertRuleForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
export default function NewAlertRulePage() {
const params = useParams();
@@ -14,6 +16,19 @@ export default function NewAlertRulePage() {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
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 (
<>

View File

@@ -645,6 +645,12 @@ export default function ConnectionLogsPage() {
</span>
)}
</div>*/}
<div>
<strong>Client Endpoint:</strong>{" "}
<span className="font-mono">
{row.clientEndpoint ?? "-"}
</span>
</div>
<div>
<strong>Site:</strong> {row.siteName ?? "-"}
{row.siteNiceId && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -22,7 +22,18 @@ import {
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Switch } from "@app/components/ui/switch";
import { Globe, MoreHorizontal, Plus } from "lucide-react";
import {
Globe,
MoreHorizontal,
Plus,
AlertCircle,
ChevronDown
} from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { AxiosResponse } from "axios";
import { build } from "@server/build";
import Image from "next/image";
@@ -38,7 +49,10 @@ import {
HttpDestinationCredenza,
parseHttpConfig
} from "@app/components/HttpDestinationCredenza";
import { S3DestinationCredenza } from "@app/components/S3DestinationCredenza";
import {
S3DestinationCredenza,
parseS3Config
} from "@app/components/S3DestinationCredenza";
import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza";
import { useTranslations } from "next-intl";
@@ -64,6 +78,42 @@ interface DestinationCardProps {
disabled?: boolean;
}
function getDestinationDisplay(destination: Destination): {
name: string;
typeLabel: string;
detail: string;
icon: React.ReactNode;
} {
if (destination.type === "s3") {
const cfg = parseS3Config(destination.config);
const detail = cfg.bucket
? `s3://${cfg.bucket}${cfg.prefix ? `/${cfg.prefix.replace(/^\/+/, "")}` : ""}`
: "";
return {
name: cfg.name,
typeLabel: "Amazon S3",
detail,
icon: (
<Image
src="/third-party/s3.png"
alt="Amazon S3"
width={16}
height={16}
className="rounded-sm"
/>
)
};
}
// Default: HTTP
const cfg = parseHttpConfig(destination.config);
return {
name: cfg.name,
typeLabel: "HTTP",
detail: cfg.url,
icon: <Globe className="h-3.5 w-3.5 text-black" />
};
}
function DestinationCard({
destination,
onToggle,
@@ -73,25 +123,25 @@ function DestinationCard({
disabled = false
}: DestinationCardProps) {
const t = useTranslations();
const cfg = parseHttpConfig(destination.config);
const { name, typeLabel, detail, icon } =
getDestinationDisplay(destination);
return (
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
{/* Top row: icon + name/type + toggle */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Squirkle icon: gray outer → white inner → black globe */}
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
<Globe className="h-3.5 w-3.5 text-black" />
{icon}
</div>
</div>
<div className="min-w-0">
<p className="font-semibold text-sm leading-tight truncate">
{cfg.name || t("streamingUnnamedDestination")}
{name || t("streamingUnnamedDestination")}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
HTTP
{typeLabel}
</p>
</div>
</div>
@@ -105,15 +155,40 @@ function DestinationCard({
/>
</div>
{/* URL preview */}
{/* Detail preview (URL for HTTP, s3:// path for S3) */}
<p className="text-xs text-muted-foreground truncate">
{cfg.url || (
{detail || (
<span className="italic">
{t("streamingNoUrlConfigured")}
</span>
)}
</p>
{/* Error indicator */}
{destination.lastError && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-left cursor-pointer rounded px-0 hover:opacity-75 transition-opacity"
>
<AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0" />
<p className="text-xs text-destructive">
{t("streamingLastSyncError")}
</p>
<ChevronDown className="h-3 w-3 text-destructive shrink-0 ml-auto" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className="w-80 text-xs break-words"
>
{destination.lastError}
</PopoverContent>
</Popover>
)}
{/* Footer: edit button + three-dots menu */}
<div className="mt-auto pt-5 flex gap-2">
<Button
@@ -485,7 +560,7 @@ export default function StreamingDestinationsPage() {
if (!v) setDeleteTarget(null);
}}
string={
parseHttpConfig(deleteTarget.config).name ||
getDestinationDisplay(deleteTarget).name ||
t("streamingDeleteDialogThisDestination")
}
title={t("streamingDeleteTitle")}
@@ -493,7 +568,7 @@ export default function StreamingDestinationsPage() {
<p>
{t("streamingDeleteDialogAreYouSure")}{" "}
<span>
{parseHttpConfig(deleteTarget.config).name ||
{getDestinationDisplay(deleteTarget).name ||
t("streamingDeleteDialogThisDestination")}
</span>
{t("streamingDeleteDialogPermanentlyRemoved")}

View File

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

View File

@@ -84,6 +84,7 @@ import {
AlertTriangle,
CircleCheck,
CircleX,
ExternalLink,
Info,
Plus,
Settings
@@ -961,13 +962,18 @@ function ProxyResourceTargetsForm({
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>

View File

@@ -82,8 +82,8 @@ import { AxiosResponse } from "axios";
import {
CircleCheck,
CircleX,
ExternalLink,
Info,
InfoIcon,
Plus,
Settings,
SquareArrowOutUpRight
@@ -1425,16 +1425,22 @@ export default function Page() {
</Button>
</div>
)}
{build === "enterprise" &&
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5">
<InfoIcon className="h-4 w-4 shrink-0 mt-0.5" />
<span>
Round robin routing will not work between
sites that are not connected to the same
node, but failover will work.
</span>
new Set(targets.map((t) => t.siteId)).size >
1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
)}
</SettingsSectionBody>

View File

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

View File

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

View File

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

559
src/app/rdp/RdpClient.tsx Normal file
View File

@@ -0,0 +1,559 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "@app/hooks/useToast";
import type {
UserInteraction,
IronError,
FileTransferProvider
} from "@devolutions/iron-remote-desktop/dist";
import type {
RdpFileTransferProvider,
FileInfo
} from "@devolutions/iron-remote-desktop-rdp/dist";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"iron-remote-desktop": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
scale?: string;
verbose?: string;
flexcenter?: string;
module?: unknown;
},
HTMLElement
>;
}
}
}
type FormState = {
username: string;
password: string;
gatewayAddress: string;
hostname: string;
domain: string;
authtoken: string;
kdcProxyUrl: string;
pcb: string;
desktopWidth: number;
desktopHeight: number;
enableClipboard: boolean;
};
const isIronError = (error: unknown): error is IronError => {
return (
typeof error === "object" &&
error !== null &&
typeof (error as IronError).backtrace === "function" &&
typeof (error as IronError).kind === "function"
);
};
export default function RdpClient() {
const [form, setForm] = useState<FormState>({
username: "Administrator",
password: "Wdvwy1W*ITK-(OK.sW?nVK%?mTl30wL0",
gatewayAddress: "ws://localhost:7171/jet/rdp",
hostname: "172.31.3.58:3389",
domain: "",
authtoken: "abc123",
kdcProxyUrl: "",
pcb: "",
desktopWidth: 1280,
desktopHeight: 720,
enableClipboard: true
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
const userInteractionRef = useRef<UserInteraction | null>(null);
const backendRef = useRef<unknown>(null);
// Holds the RdpFileTransferProvider constructor so we can create a fresh
// instance per session (avoids stale upload state across reconnects).
const fileTransferClassRef = useRef<typeof RdpFileTransferProvider | null>(
null
);
// Active session's provider instance; replaced on each connect.
const fileTransferRef = useRef<RdpFileTransferProvider | null>(null);
const extensionsRef = useRef<{
displayControl: (enable: boolean) => unknown;
preConnectionBlob: (pcb: string) => unknown;
kdcProxyUrl: (url: string) => unknown;
} | null>(null);
// Load the iron-remote-desktop modules client-side and register the
// `<iron-remote-desktop>` custom element.
useEffect(() => {
let cancelled = false;
(async () => {
const [coreMod, rdpMod] = await Promise.all([
import("@devolutions/iron-remote-desktop/dist"),
import("@devolutions/iron-remote-desktop-rdp/dist")
]);
if (cancelled) return;
await rdpMod.init("INFO");
backendRef.current = rdpMod.Backend;
extensionsRef.current = {
displayControl: rdpMod.displayControl,
preConnectionBlob: rdpMod.preConnectionBlob,
kdcProxyUrl: rdpMod.kdcProxyUrl
};
// Store the class; a fresh instance is created per session.
fileTransferClassRef.current =
rdpMod.RdpFileTransferProvider as unknown as typeof RdpFileTransferProvider;
// Importing the package registers the custom element as a side
// effect. Touch the default export to avoid tree-shaking.
void coreMod;
setModuleReady(true);
})().catch((err) => {
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
description: `${err}`
});
});
return () => {
cancelled = true;
};
}, []);
// Attach the "ready" listener synchronously the moment the custom
// element mounts. The custom element dispatches `ready` from its own
// `onMount`, so a deferred useEffect can race and miss it.
const remoteElementRef = (el: HTMLElement | null) => {
if (!el) return;
const onReady = (e: Event) => {
const event = e as CustomEvent;
userInteractionRef.current = event.detail.irgUserInteraction;
};
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
return;
}
if (form.authtoken === "") {
toast({
variant: "destructive",
title: "Missing auth token",
description:
"An auth token is required to connect through the gateway"
});
return;
}
toast({
title: "Connecting...",
description: "Connection in progress"
});
userInteraction.setEnableClipboard(form.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection.
fileTransferRef.current?.dispose();
const ProviderClass = fileTransferClassRef.current;
const fileTransfer = ProviderClass ? new ProviderClass() : null;
fileTransferRef.current = fileTransfer;
if (fileTransfer) {
// Auto-download files when the remote copies them to clipboard.
fileTransfer.on("files-available", (files: FileInfo[]) => {
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.isDirectory) continue;
const { completion } = fileTransfer.downloadFile(file, i);
completion
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
})
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
description: `${err}`
});
});
}
});
// Notify when individual uploads complete (remote pasted a file).
fileTransfer.on("upload-complete", (file: File) => {
toast({ title: `Uploaded: ${file.name}` });
});
// Register with the web component so CLIPRDR extensions are
// wired up before connect() builds the session.
userInteraction.enableFileTransfer(
fileTransfer as unknown as FileTransferProvider
);
}
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withDestination(form.hostname)
.withProxyAddress(form.gatewayAddress)
.withServerDomain(form.domain)
.withAuthToken(form.authtoken)
.withDesktopSize({
width: form.desktopWidth,
height: form.desktopHeight
})
.withExtension(exts.displayControl(true));
if (form.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb));
}
if (form.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
}
try {
const sessionInfo = await userInteraction.connect(builder.build());
toast({ title: "Connected" });
setShowLogin(false);
userInteraction.setVisibility(true);
const termInfo = await sessionInfo.run();
fileTransferRef.current?.dispose();
fileTransferRef.current = null;
toast({
title: "Session terminated",
description: termInfo.reason()
});
setShowLogin(true);
} catch (err) {
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
}
}
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
const u = ui();
if (!u) return;
if (cursorOverrideActive) {
u.setCursorStyleOverride(null);
} else {
u.setCursorStyleOverride('url("crosshair.png") 7 7, default');
}
setCursorOverrideActive((v) => !v);
};
return (
<div className="min-h-screen bg-background">
{showLogin && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">
RDP Test Connection
</h1>
<div className="space-y-4">
<Field label="Hostname" id="hostname">
<Input
id="hostname"
value={form.hostname}
onChange={(e) =>
update("hostname", e.target.value)
}
/>
</Field>
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="Password" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Field label="Gateway Address" id="gatewayAddress">
<Input
id="gatewayAddress"
value={form.gatewayAddress}
onChange={(e) =>
update("gatewayAddress", e.target.value)
}
/>
</Field>
{/* <Field label="Auth Token" id="authtoken">
<Input
id="authtoken"
value={form.authtoken}
onChange={(e) =>
update("authtoken", e.target.value)
}
/>
</Field>
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
value={form.pcb}
onChange={(e) => update("pcb", e.target.value)}
/>
</Field> */}
<div className="grid grid-cols-2 gap-4">
<Field label="Desktop Width" id="desktopWidth">
<Input
id="desktopWidth"
type="number"
value={form.desktopWidth}
onChange={(e) =>
update(
"desktopWidth",
Number(e.target.value) || 0
)
}
/>
</Field>
<Field label="Desktop Height" id="desktopHeight">
<Input
id="desktopHeight"
type="number"
value={form.desktopHeight}
onChange={(e) =>
update(
"desktopHeight",
Number(e.target.value) || 0
)
}
/>
</Field>
</div>
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
<Input
id="kdcProxyUrl"
value={form.kdcProxyUrl}
onChange={(e) =>
update("kdcProxyUrl", e.target.value)
}
/>
</Field> */}
<div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
onCheckedChange={(checked) =>
update("enableClipboard", checked === true)
}
/>
<Label htmlFor="enable_clipboard">
Enable Clipboard
</Label>
</div>
<Button
onClick={startSession}
disabled={!moduleReady}
className="w-full"
>
{moduleReady ? "Connect" : "Loading module..."}
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: showLogin ? "none" : "flex" }}
>
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
</Button>
{/* <Button
size="sm"
variant="secondary"
onClick={toggleCursorKind}
>
Toggle cursor
</Button> */}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ft = fileTransferRef.current;
if (!ft) return;
const files = await ft.showFilePicker({
multiple: true
});
if (files.length === 0) return;
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
description: `${err}`
});
}
}}
>
Upload files
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
ui()?.shutdown();
setShowLogin(true);
}}
>
Terminate
</Button>
<label className="ml-2 flex items-center gap-2">
<input
type="checkbox"
checked={unicodeMode}
onChange={(e) => {
setUnicodeMode(e.target.checked);
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
</label>
</div>
{moduleReady && (
<iron-remote-desktop
ref={remoteElementRef}
verbose="true"
scale="fit"
flexcenter="true"
module={backendRef.current}
/>
)}
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

11
src/app/rdp/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import RdpClient from "./RdpClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP Test"
};
export default function RdpPage() {
return <RdpClient />;
}

355
src/app/ssh/SshClient.tsx Normal file
View File

@@ -0,0 +1,355 @@
"use client";
import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type FormState = {
gatewayAddress: string;
hostname: string;
port: string;
username: string;
password: string;
authToken: string;
};
export default function SshClient() {
const [form, setForm] = useState<FormState>({
gatewayAddress: "ws://localhost:7171/jet/ssh",
hostname: "",
port: "22",
username: "",
password: "",
authToken: "abc123"
});
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
null
);
const wsRef = useRef<WebSocket | null>(null);
// Mount the terminal div once connected.
useEffect(() => {
if (!connected || !terminalRef.current) return;
let cancelled = false;
(async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] =
await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links")
]);
if (cancelled || !terminalRef.current) return;
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: {
background: "#0d0d0d",
foreground: "#f0f0f0"
},
scrollback: 5000
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data }));
}
});
// Send resize events.
terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
});
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: "resize", cols, rows })
);
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [connected]);
// Refit terminal when the window resizes.
useEffect(() => {
const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
wsRef.current?.close();
xtermRef.current?.dispose();
};
}, []);
function connect() {
setError(null);
setConnecting(true);
const url = new URL(form.gatewayAddress);
// Pass connection parameters as query params so the proxy can route
// before any application-level framing is needed.
url.searchParams.set("host", form.hostname);
url.searchParams.set("port", form.port);
url.searchParams.set("username", form.username);
// Auth token is sent as a query param; the proxy validates it before
// forwarding any data.
url.searchParams.set("authToken", form.authToken);
const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws;
ws.onopen = () => {
// Send the password (or empty string) as the first frame so the
// proxy can complete SSH authentication before piping pty data.
ws.send(JSON.stringify({ type: "auth", password: form.password }));
setConnecting(false);
setConnected(true);
};
ws.onmessage = (evt) => {
if (typeof evt.data === "string") {
try {
const msg = JSON.parse(evt.data as string) as {
type: string;
data?: string;
error?: string;
};
if (msg.type === "data" && msg.data) {
xtermRef.current?.write(msg.data);
} else if (msg.type === "error") {
xtermRef.current?.writeln(
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
);
}
} catch {
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => xtermRef.current?.write(t));
}
};
ws.onerror = () => {
setConnecting(false);
setConnected(false);
setError("WebSocket connection failed");
};
ws.onclose = (evt) => {
setConnecting(false);
setConnected(false);
xtermRef.current?.writeln(
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
);
};
}
function disconnect() {
wsRef.current?.close();
xtermRef.current?.dispose();
xtermRef.current = null;
setConnected(false);
}
return (
<div className="flex flex-col h-screen bg-black text-white p-4 gap-4">
<h1 className="text-xl font-semibold text-white">SSH Terminal</h1>
{!connected && (
<div className="bg-neutral-900 rounded-lg p-6 max-w-lg w-full mx-auto flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 flex flex-col gap-1">
<Label
htmlFor="gatewayAddress"
className="text-neutral-300"
>
Gateway Address
</Label>
<Input
id="gatewayAddress"
value={form.gatewayAddress}
onChange={(e) =>
setForm({
...form,
gatewayAddress: e.target.value
})
}
placeholder="ws://localhost:7171/jet/ssh"
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="hostname"
className="text-neutral-300"
>
Host
</Label>
<Input
id="hostname"
value={form.hostname}
onChange={(e) =>
setForm({
...form,
hostname: e.target.value
})
}
placeholder="192.168.1.1"
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="port" className="text-neutral-300">
Port
</Label>
<Input
id="port"
value={form.port}
onChange={(e) =>
setForm({ ...form, port: e.target.value })
}
placeholder="22"
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="username"
className="text-neutral-300"
>
Username
</Label>
<Input
id="username"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="flex flex-col gap-1">
<Label
htmlFor="password"
className="text-neutral-300"
>
Password
</Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<Label
htmlFor="authToken"
className="text-neutral-300"
>
Auth Token
</Label>
<Input
id="authToken"
value={form.authToken}
onChange={(e) =>
setForm({
...form,
authToken: e.target.value
})
}
className="bg-neutral-800 border-neutral-700 text-white"
/>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<Button
onClick={connect}
disabled={
connecting ||
!form.hostname ||
!form.username ||
!form.authToken
}
className="w-full"
>
{connecting ? "Connecting…" : "Connect"}
</Button>
</div>
)}
{connected && (
<div className="flex flex-col flex-1 gap-2 min-h-0">
<div className="flex justify-end">
<Button
variant="destructive"
size="sm"
onClick={disconnect}
>
Disconnect
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 rounded overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}

11
src/app/ssh/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import SshClient from "./SshClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH Terminal"
};
export default function SshPage() {
return <SshClient />;
}

271
src/app/vnc/VncClient.tsx Normal file
View File

@@ -0,0 +1,271 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@app/hooks/useToast";
type FormState = {
proxyAddress: string;
host: string;
port: string;
password: string;
authToken: string;
};
export default function VncClient() {
const [form, setForm] = useState<FormState>({
proxyAddress: "ws://localhost:7171/jet/vnc",
host: "",
port: "5900",
password: "",
authToken: "abc123"
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
// Disconnect and clean up the RFB instance.
const disconnect = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
rfbRef.current = null;
}
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const connect = async () => {
if (!form.host) {
toast({
variant: "destructive",
title: "Missing host",
description: "Enter the VNC server hostname or IP"
});
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
disconnect();
// noVNC has no ESM default export — import the module dynamically to
// keep it out of the server bundle, then grab the default export.
let RFB: new (
target: HTMLElement,
url: string,
options?: Record<string, unknown>
) => unknown;
try {
// @ts-expect-error — @novnc/novnc ships plain JS with no bundled types
const mod = await import("@novnc/novnc");
RFB = mod.default ?? mod;
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
description: `${err}`
});
return;
}
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<host>&port=<port>
const base = form.proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
authToken: form.authToken,
host: form.host,
port: form.port
});
const wsUrl = `${base}?${params.toString()}`;
toast({ title: "Connecting…", description: wsUrl });
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
toast({ title: "Connected" });
setConnected(true);
});
rfb.addEventListener(
"disconnect",
(e: { detail: { clean: boolean } }) => {
rfbRef.current = null;
setConnected(false);
toast({
title: e.detail.clean ? "Disconnected" : "Connection lost",
variant: e.detail.clean ? "default" : "destructive"
});
}
);
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
}
);
rfbRef.current = rfb;
};
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">
VNC Test Connection
</h1>
<div className="space-y-4">
<Field label="VNC Host" id="host">
<Input
id="host"
placeholder="192.168.1.100"
value={form.host}
onChange={(e) => update("host", e.target.value)}
/>
</Field>
<Field label="VNC Port" id="port">
<Input
id="port"
type="number"
value={form.port}
onChange={(e) => update("port", e.target.value)}
/>
</Field>
<Field label="Password (optional)" id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
<Field label="Proxy Address" id="proxyAddress">
<Input
id="proxyAddress"
value={form.proxyAddress}
onChange={(e) =>
update("proxyAddress", e.target.value)
}
/>
</Field>
<Field label="Auth Token" id="authToken">
<Input
id="authToken"
value={form.authToken}
onChange={(e) =>
update("authToken", e.target.value)
}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
</div>
)}
<div
className="flex h-screen flex-col bg-neutral-900"
style={{ display: connected ? "flex" : "none" }}
>
<div className="flex items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="secondary"
onClick={() => {
if (rfbRef.current) {
rfbRef.current.sendCtrlAltDel();
}
}}
>
Ctrl+Alt+Del
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
navigator.clipboard
?.readText()
.then((text) => {
rfbRef.current?.clipboardPasteFrom(text);
})
.catch(() => {});
}}
>
Paste clipboard
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Disconnect
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
style={{ background: "#000" }}
/>
</div>
</div>
);
}
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}

11
src/app/vnc/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import VncClient from "./VncClient";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC Test"
};
export default function VncPage() {
return <VncClient />;
}

View File

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

View File

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

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
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
)}
{...props}

View File

@@ -13,6 +13,8 @@ import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Lock } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
interface DomainPageClientProps {
initialDomain: GetDomainResponse;
@@ -49,7 +51,22 @@ export default function DomainPageClient({
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={domain.baseDomain}
title={
<span className="flex items-center gap-2">
{domain.baseDomain}
{domain.configManaged && (
<Badge
variant="secondary"
className="flex items-center gap-1 text-sm font-normal"
>
<Lock className="h-3 w-3" />
{t("configManaged", {
fallback: "Config Managed"
})}
</Badge>
)}
</span>
}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
@@ -90,4 +107,4 @@ export default function DomainPageClient({
</div>
</>
);
}
}

View File

@@ -16,6 +16,7 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { Lock } from "lucide-react";
import { useTranslations } from "next-intl";
import CreateDomainForm from "@app/components/CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
@@ -72,7 +73,11 @@ export default function DomainsTable({ domains, orgId }: Props) {
const { org } = useOrgContext();
const queryClient = useQueryClient();
const { data: rawDomains, isRefetching, refetch } = useQuery({
const {
data: rawDomains,
isRefetching,
refetch
} = useQuery({
...orgQueries.domains({ orgId }),
initialData: domains as any,
refetchInterval: durationToMs(10, "seconds")
@@ -80,12 +85,15 @@ export default function DomainsTable({ domains, orgId }: Props) {
const tableData = useMemo(
() =>
(rawDomains ?? []).map((d) => ({
...d,
baseDomain: toUnicode(d.baseDomain),
type: d.type ?? "",
errorMessage: d.errorMessage ?? null
} as DomainRow)),
(rawDomains ?? []).map(
(d) =>
({
...d,
baseDomain: toUnicode(d.baseDomain),
type: d.type ?? "",
errorMessage: d.errorMessage ?? null
}) as DomainRow
),
[rawDomains]
);
@@ -198,12 +206,17 @@ export default function DomainsTable({ domains, orgId }: Props) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="red" className="cursor-help">
<Badge
variant="red"
className="cursor-help"
>
{t("failed", { fallback: "Failed" })}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="break-words">{errorMessage}</p>
<p className="break-words">
{errorMessage}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -220,12 +233,17 @@ export default function DomainsTable({ domains, orgId }: Props) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="yellow" className="cursor-help">
<Badge
variant="yellow"
className="cursor-help"
>
{t("pending")}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="break-words">{errorMessage}</p>
<p className="break-words">
{errorMessage}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -253,6 +271,25 @@ export default function DomainsTable({ domains, orgId }: Props) {
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<span className="flex items-center gap-2">
{domain.baseDomain}
{domain.configManaged && (
<Badge
variant="secondary"
className="flex items-center gap-1 text-xs font-normal"
>
<Lock className="h-3 w-3" />
{t("configManaged", {
fallback: "Config Managed"
})}
</Badge>
)}
</span>
);
}
},
...(env.env.flags.usePangolinDns ? [typeColumn] : []),
@@ -283,16 +320,18 @@ export default function DomainsTable({ domains, orgId }: Props) {
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
{!domain.configManaged && (
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{domain.failed && (
@@ -315,7 +354,9 @@ export default function DomainsTable({ domains, orgId }: Props) {
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<Button variant={"outline"}>
{t("edit")}
{domain.configManaged
? t("view", { fallback: "View" })
: t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>

View File

@@ -19,7 +19,8 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Textarea } from "@app/components/ui/textarea";
import { Checkbox } from "@app/components/ui/checkbox";
import { Plus, X } from "lucide-react";
import { Plus, X, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
@@ -56,6 +57,8 @@ export interface Destination {
sendActionLogs: boolean;
sendConnectionLogs: boolean;
sendRequestLogs: boolean;
lastError: string | null;
lastErrorAt: number | null;
createdAt: number;
updatedAt: number;
}
@@ -122,9 +125,7 @@ function HeadersEditor({ headers, onChange }: HeadersEditorProps) {
/>
<Input
value={h.value}
onChange={(e) =>
updateRow(i, "value", e.target.value)
}
onChange={(e) => updateRow(i, "value", e.target.value)}
placeholder={t("httpDestHeaderValuePlaceholder")}
className="flex-1"
/>
@@ -200,10 +201,7 @@ export function HttpDestinationCredenza({
if (!raw) return null;
try {
const parsed = new URL(raw);
if (
parsed.protocol !== "http:" &&
parsed.protocol !== "https:"
) {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return t("httpDestUrlErrorHttpRequired");
}
if (build === "saas" && parsed.protocol !== "https:") {
@@ -216,9 +214,7 @@ export function HttpDestinationCredenza({
})();
const isValid =
cfg.name.trim() !== "" &&
cfg.url.trim() !== "" &&
urlError === null;
cfg.name.trim() !== "" && cfg.url.trim() !== "" && urlError === null;
async function handleSave() {
if (!isValid) return;
@@ -253,10 +249,7 @@ export function HttpDestinationCredenza({
title: editing
? t("httpDestUpdateFailed")
: t("httpDestCreateFailed"),
description: formatAxiosError(
e,
t("streamingUnexpectedError")
)
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setSaving(false);
@@ -280,6 +273,14 @@ export function HttpDestinationCredenza({
</CredenzaHeader>
<CredenzaBody>
{editing?.lastError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="break-words">
{editing.lastError}
</AlertDescription>
</Alert>
)}
<HorizontalTabs
clientSide
items={[
@@ -357,7 +358,9 @@ export function HttpDestinationCredenza({
{t("httpDestAuthNoneTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthNoneDescription")}
{t(
"httpDestAuthNoneDescription"
)}
</p>
</div>
</div>
@@ -375,15 +378,21 @@ export function HttpDestinationCredenza({
htmlFor="auth-bearer"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBearerTitle")}
{t(
"httpDestAuthBearerTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBearerDescription")}
{t(
"httpDestAuthBearerDescription"
)}
</p>
</div>
{cfg.authType === "bearer" && (
<Input
placeholder={t("httpDestAuthBearerPlaceholder")}
placeholder={t(
"httpDestAuthBearerPlaceholder"
)}
value={
cfg.bearerToken ?? ""
}
@@ -411,15 +420,21 @@ export function HttpDestinationCredenza({
htmlFor="auth-basic"
className="cursor-pointer font-medium"
>
{t("httpDestAuthBasicTitle")}
{t(
"httpDestAuthBasicTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthBasicDescription")}
{t(
"httpDestAuthBasicDescription"
)}
</p>
</div>
{cfg.authType === "basic" && (
<Input
placeholder={t("httpDestAuthBasicPlaceholder")}
placeholder={t(
"httpDestAuthBasicPlaceholder"
)}
value={
cfg.basicCredentials ??
""
@@ -448,16 +463,22 @@ export function HttpDestinationCredenza({
htmlFor="auth-custom"
className="cursor-pointer font-medium"
>
{t("httpDestAuthCustomTitle")}
{t(
"httpDestAuthCustomTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestAuthCustomDescription")}
{t(
"httpDestAuthCustomDescription"
)}
</p>
</div>
{cfg.authType === "custom" && (
<div className="flex gap-2">
<Input
placeholder={t("httpDestAuthCustomHeaderNamePlaceholder")}
placeholder={t(
"httpDestAuthCustomHeaderNamePlaceholder"
)}
value={
cfg.customHeaderName ??
""
@@ -472,7 +493,9 @@ export function HttpDestinationCredenza({
className="flex-1"
/>
<Input
placeholder={t("httpDestAuthCustomHeaderValuePlaceholder")}
placeholder={t(
"httpDestAuthCustomHeaderValuePlaceholder"
)}
value={
cfg.customHeaderValue ??
""
@@ -593,10 +616,14 @@ export function HttpDestinationCredenza({
htmlFor="fmt-json-array"
className="cursor-pointer font-medium"
>
{t("httpDestFormatJsonArrayTitle")}
{t(
"httpDestFormatJsonArrayTitle"
)}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatJsonArrayDescription")}
{t(
"httpDestFormatJsonArrayDescription"
)}
</p>
</div>
</div>
@@ -616,7 +643,9 @@ export function HttpDestinationCredenza({
{t("httpDestFormatNdjsonTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatNdjsonDescription")}
{t(
"httpDestFormatNdjsonDescription"
)}
</p>
</div>
</div>
@@ -636,7 +665,9 @@ export function HttpDestinationCredenza({
{t("httpDestFormatSingleTitle")}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestFormatSingleDescription")}
{t(
"httpDestFormatSingleDescription"
)}
</p>
</div>
</div>
@@ -717,7 +748,9 @@ export function HttpDestinationCredenza({
{t("httpDestConnectionLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestConnectionLogsDescription")}
{t(
"httpDestConnectionLogsDescription"
)}
</p>
</div>
</div>
@@ -739,7 +772,9 @@ export function HttpDestinationCredenza({
{t("httpDestRequestLogsTitle")}
</label>
<p className="text-xs text-muted-foreground mt-0.5">
{t("httpDestRequestLogsDescription")}
{t(
"httpDestRequestLogsDescription"
)}
</p>
</div>
</div>
@@ -764,10 +799,12 @@ export function HttpDestinationCredenza({
loading={saving}
disabled={!isValid || saving}
>
{editing ? t("httpDestSaveChanges") : t("httpDestCreateDestination")}
{editing
? t("httpDestSaveChanges")
: t("httpDestCreateDestination")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
}

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