Compare commits

..

113 Commits

Author SHA1 Message Date
Owen
c4b3656fad Update UI to support additions on the resource 2026-05-06 10:09:05 -07:00
Owen
54c1dd3bae Make path the default 2026-05-05 21:05:42 -07:00
Owen
a8f4d2b7d1 Add new user and role selectors for pagination 2026-05-05 20:53:36 -07:00
Owen
51f1693dbd Merge branch 'dev' into resource-policies 2026-05-05 18:02:27 -07:00
Owen
b33a6e6fac Wipe the old tables if you are using inline 2026-05-04 20:54:43 -07:00
Owen
fc2c13a686 Add policies to blueprints 2026-05-04 20:44:04 -07:00
Owen
f4602a120e Merge branch 'dev' into resource-policies 2026-05-04 17:57:09 -07:00
Owen
7ccceeea0d Ignore extra sqlite files 2026-05-04 17:43:02 -07:00
Owen
f81f78f294 Merge branch 'dev' into resource-policies 2026-05-04 17:41:49 -07:00
Owen
6cab223f12 Adjust verify session queries to use policies 2026-05-04 17:30:10 -07:00
Owen
7b05c02508 Adjust translation 2026-05-04 16:19:04 -07:00
Owen
5922bfb1a0 Fix API endpoint action issues 2026-05-04 16:01:40 -07:00
Owen
43f2e32231 Paywall resource policies 2026-05-04 15:30:49 -07:00
Owen
20ebdc6289 Fix openapi zod issue error 2026-05-04 15:04:54 -07:00
Owen
a80ae49a33 Support multiple roles 2026-05-04 14:54:20 -07:00
Owen
660197eef1 Merge branch 'feat/resource-policies' into resource-policies 2026-05-04 14:40:44 -07:00
Fred KISSIE
f3eb823bc3 🐛 fix sqlite tables 2026-03-12 22:36:29 +01:00
Fred KISSIE
61c13db090 Merge branch 'dev' into feat/resource-policies 2026-03-12 22:19:37 +01:00
Fred KISSIE
ccbd793f52 💬 show error 2026-03-12 22:13:27 +01:00
Fred KISSIE
d13e6896a8 ♻️ update 2026-03-12 22:11:39 +01:00
Fred KISSIE
83a36ead10 ♻️ show success toast on resource policy update 2026-03-12 20:22:16 +01:00
Fred KISSIE
b61b74b0b5 💬 update text 2026-03-12 20:04:02 +01:00
Fred KISSIE
01b068c50f ♻️ do not edit tags if readonly 2026-03-12 18:53:18 +01:00
Fred KISSIE
fee44ce960 navigate to policy to edit 2026-03-12 18:52:13 +01:00
Fred KISSIE
1906504a86 update shared policy when selected 2026-03-12 18:35:50 +01:00
Fred KISSIE
36bcba332c 🚧 wip 2026-03-11 05:18:22 +01:00
Fred KISSIE
304ab1964c 🚧 wip 2026-03-11 04:21:55 +01:00
Fred KISSIE
b286096c7b 🌐 text 2026-03-11 03:47:31 +01:00
Fred KISSIE
a22a4b6e74 ♻️ mark forms as readonly 2026-03-11 03:47:15 +01:00
Fred KISSIE
9a680d2374 update resource should update policy 2026-03-11 03:46:40 +01:00
Fred KISSIE
f80e212b07 🚧 wip 2026-03-11 00:27:27 +01:00
Fred KISSIE
8a39b3fd45 🙈 do not include solo.yml to git 2026-03-10 18:55:12 +01:00
Fred KISSIE
61ec938b00 🚧 WIP 2026-03-10 18:54:26 +01:00
Fred KISSIE
6686de6788 ♻️ refactor 2026-03-10 17:48:17 +01:00
Fred KISSIE
79636cbb30 ♻️ delete default resource policy ID when deleting a resource 2026-03-10 17:38:19 +01:00
Fred KISSIE
2fa1bc6cdc 🚧 wip 2026-03-07 03:55:30 +01:00
Fred KISSIE
c5f6d822ca ♻️ refactor auth info to use resource policies 2026-03-07 03:45:10 +01:00
Fred KISSIE
4de4bf9625 use resource policies for auth check 2026-03-07 03:35:26 +01:00
Fred KISSIE
5d956080f2 create default policy when creating a resource 2026-03-07 02:29:36 +01:00
Fred KISSIE
f8e18de2fc ♻️ prevent deleting resource policies if they have attached resources 2026-03-07 01:12:10 +01:00
Fred KISSIE
884482ec35 ♻️ delete resource policy endpoint 2026-03-06 23:57:23 +01:00
Fred KISSIE
9b43948fa4 delete resource policy endpoint 2026-03-06 22:39:44 +01:00
Fred KISSIE
bcd6cd99cc 🚧 wip 2026-03-06 04:37:57 +01:00
Fred KISSIE
37ceba6b81 💄 show attached resources in policy list 2026-03-06 04:36:12 +01:00
Fred KISSIE
dfe42e9016 ♻️ refactor 2026-03-06 04:03:40 +01:00
Fred KISSIE
38aa2dace8 ♻️ show list of resources on policy list 2026-03-06 04:03:25 +01:00
Fred KISSIE
136c3eff0c ♻️ padding bottom 2026-03-05 19:46:16 +01:00
Fred KISSIE
642999c8b1 ♻️ separate create form into multiple ones 2026-03-05 19:45:13 +01:00
Fred KISSIE
c5fc49b4fa 🚧 wip 2026-03-05 19:31:19 +01:00
Fred KISSIE
cd5a38b1eb 🚧 WIP: create policy form 2026-03-05 18:56:35 +01:00
Fred KISSIE
595842c2c9 finish create policy endpoint 2026-03-05 18:48:33 +01:00
Fred KISSIE
82d5276ade 🚧 wip: create resource policy 2026-03-05 18:24:04 +01:00
Fred KISSIE
51eb782831 🚧 wip 2026-03-05 18:14:46 +01:00
Fred KISSIE
de2980e1bc apply rules on resource policies 2026-03-05 18:13:30 +01:00
Fred KISSIE
8a3c0d9a08 ♻️ add openapi schema types 2026-03-05 17:51:55 +01:00
Fred KISSIE
1a5e9f1005 🚧 resource policy rules 2026-03-04 19:31:59 +01:00
Fred KISSIE
f42c013f33 ♻️ refactor 2026-03-04 17:41:55 +01:00
Fred KISSIE
42c9bda939 Merge branch 'dev' into feat/resource-policies 2026-03-04 16:46:33 +01:00
Fred KISSIE
cbce9fae3a 🚧 wip 2026-03-04 16:36:49 +01:00
Fred KISSIE
e44b15ecd5 set opt email whitelist 2026-03-04 01:54:50 +01:00
Fred KISSIE
7f6ca31757 🚧 Email whiteList for resource policy 2026-03-04 01:46:56 +01:00
Fred KISSIE
a1eb248474 🔨 remove docker compose mail 2026-03-04 01:10:48 +01:00
Fred KISSIE
be2b1fd1ce 🚧 wip: email whitelist 2026-03-03 20:26:17 +01:00
Fred KISSIE
20b65f549e Update resource policy pincode 2026-03-03 19:49:24 +01:00
Fred KISSIE
1dc8be373c 🚧 wip: add password 2026-03-03 18:54:35 +01:00
Fred KISSIE
22b2e6b3d4 🚧 wip: separating form sections 2026-03-03 18:41:04 +01:00
Fred KISSIE
89e7107a47 ♻️ use put and return 200 OK 2026-03-03 03:31:43 +01:00
Fred KISSIE
0a69131c38 ♻️ merge header auth & extended compability to one table 2026-03-03 03:27:02 +01:00
Fred KISSIE
590f2c29b3 🚧 prepare tables for auth methods 2026-03-03 03:20:03 +01:00
Fred KISSIE
0ddcce6fe1 🗃️ create resource policy specific tables for auth methods 2026-03-03 02:47:21 +01:00
Fred KISSIE
8a54fb7f23 🚧 auth methods 2026-03-03 02:11:05 +01:00
Fred KISSIE
5c280b024e update policy access control 2026-03-03 01:33:37 +01:00
Fred KISSIE
033cc62ce7 🚧 wip 2026-03-02 19:37:23 +01:00
Fred KISSIE
4c69b7a64e update policy access control 2026-03-02 19:26:51 +01:00
Fred KISSIE
e7ab9b3f37 🚧 wip 2026-03-02 18:32:08 +01:00
Fred KISSIE
3143662f82 Merge branch 'dev' into feat/resource-policies 2026-03-02 15:53:00 +01:00
Fred KISSIE
18964ba2a3 🚧 wip 2026-02-28 14:22:41 +01:00
Fred KISSIE
f862404c5c Merge branch 'dev' into feat/resource-policies 2026-02-28 01:17:51 +01:00
Fred KISSIE
c292578f80 Merge branch 'dev' into feat/resource-policies 2026-02-28 01:08:12 +01:00
Fred KISSIE
7b02d4104d 🚧 wip 2026-02-28 00:47:27 +01:00
Fred KISSIE
2ef5d90e13 ♻️ update policy in integration API 2026-02-27 04:24:33 +01:00
Fred KISSIE
d6a8021613 🚧 wip: update resource policy form 2026-02-27 04:21:20 +01:00
Fred KISSIE
c5231d37f6 🚧 wip 2026-02-26 19:20:15 +01:00
Fred KISSIE
4d803a40c9 🚧 wip 2026-02-25 06:00:19 +01:00
Fred KISSIE
1d709b551a create policy endpoitn 2026-02-24 06:31:43 +01:00
Fred KISSIE
335411de4c ♻️ create table for resource policies associations with users 2026-02-24 03:05:51 +01:00
Fred KISSIE
0e4abdf4b6 ♻️ usewatch 2026-02-20 02:06:23 +01:00
Fred KISSIE
267b40b73c 🚧 wip 2026-02-19 05:27:05 +01:00
Fred KISSIE
ba9a0c5e3c ♻️ refactor 2026-02-19 05:23:20 +01:00
Fred KISSIE
9e0b7ff0d7 ♻️ some other ux changes 2026-02-19 05:22:06 +01:00
Fred KISSIE
003bf7fdf3 🚸 hide otp, rules and resource rules config by default 2026-02-19 04:59:51 +01:00
Fred KISSIE
c3fdda026b ♻️ separate into diff components 2026-02-19 04:36:42 +01:00
Fred KISSIE
a53363d064 💄 include rules in create policy form 2026-02-19 03:23:54 +01:00
Fred KISSIE
ee21e1faa7 🚧 list authentication items from policy APIs 2026-02-18 05:08:42 +01:00
Fred KISSIE
e409a34a09 🚧 create policy form 2026-02-18 05:08:27 +01:00
Fred KISSIE
7177ab7f77 🚧 create resource policy table 2026-02-14 05:08:41 +01:00
Fred KISSIE
801f6fb661 🚚 move policies page to (private) folder 2026-02-14 05:03:40 +01:00
Fred KISSIE
805d82b8d9 policies table 2026-02-14 04:59:35 +01:00
Fred KISSIE
bd6d790495 Merge branch 'refactor/paginated-tables' into feat/resource-policies 2026-02-14 04:25:43 +01:00
Fred KISSIE
2305163474 🚧 wip 2026-02-14 03:24:01 +01:00
Fred KISSIE
dda53dcb16 Merge branch 'refactor/paginated-tables' into feat/resource-policies 2026-02-13 06:05:32 +01:00
Fred KISSIE
2c3e768867 🚧 wip: list resource endpoints finished 2026-02-13 05:54:45 +01:00
Fred KISSIE
8d682ed9ad 🚧 list policies endpoint + list policies table 2026-02-13 05:39:35 +01:00
Fred KISSIE
47fe497ca1 🚧 add sidebar item for policies 2026-02-13 05:39:16 +01:00
Fred KISSIE
4d5f364663 ♻️ use the correct types 2026-02-13 05:38:57 +01:00
Fred KISSIE
c3db8b972f ♻️ schema updates for policies 2026-02-13 05:36:42 +01:00
Fred KISSIE
cfced63ba1 Merge branch 'dev' into feat/resource-policies 2026-02-13 02:14:14 +01:00
Fred KISSIE
51aa55f963 revert changes already included in another PR 2026-02-13 00:25:00 +01:00
Fred KISSIE
e7df24841e ♻️ update sqlite DB 2026-02-12 03:50:30 +01:00
Fred KISSIE
e6fd4c32c4 ♻️ update DB 2026-02-12 03:50:09 +01:00
Fred KISSIE
f6590aedbd ♻️ add default sso: true to resource policy table 2026-02-12 03:22:24 +01:00
Fred KISSIE
3cb9e02533 ♻️ make resourcePolicyId non nullable 2026-02-12 02:56:45 +01:00
Fred KISSIE
4d792350ef 🗃️ add resource policy table 2026-02-12 02:53:04 +01:00
117 changed files with 14015 additions and 3473 deletions

5
.gitignore vendored
View File

@@ -17,9 +17,9 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
*.db *.db
*.sqlite *.sqlite*
!Dockerfile.sqlite !Dockerfile.sqlite
*.sqlite3 *.sqlite3*
*.log *.log
.machinelogs*.json .machinelogs*.json
*-audit.json *-audit.json
@@ -54,3 +54,4 @@ hydrateSaas.ts
CLAUDE.md CLAUDE.md
drizzle.config.ts drizzle.config.ts
server/setup/migrations.ts server/setup/migrations.ts
solo.yml

View File

@@ -1,4 +1,4 @@
FROM node:26-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -204,11 +204,33 @@
"resourcesSearch": "Search resources...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource", "resourceErrorDelte": "Error deleting resource",
"resourcePoliciesTitle": "Manage Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
"resourcePoliciesAttachedResources": "{count} resource(s)",
"resourcePoliciesAttachedResourcesEmpty": "no resources",
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
"resourcePoliciesSearch": "Search policies...",
"resourcePoliciesAdd": "Add Policy",
"resourcePoliciesDefaultBadgeText": "Default policy",
"resourcePoliciesCreate": "Create Resource Policy",
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
"resourcePolicyName": "Policy Name",
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
"resourcePolicyNamePlaceholder": "e.g. Internal Access Policy",
"resourcePoliciesSeeAll": "See All Policies",
"resourcePolicyAuthMethodAdd": "Add Authentication Method",
"resourcePolicyOtpEmailAdd": "Add OTP emails",
"resourcePolicyRulesAdd": "Add Rules",
"resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods",
"resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources",
"rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy",
"authentication": "Authentication", "authentication": "Authentication",
"protected": "Protected", "protected": "Protected",
"notProtected": "Not Protected", "notProtected": "Not Protected",
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
"resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.",
"resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?",
"resourceHTTP": "HTTPS Resource", "resourceHTTP": "HTTPS Resource",
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.", "resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
"resourceRaw": "Raw TCP/UDP Resource", "resourceRaw": "Raw TCP/UDP Resource",
@@ -249,6 +271,8 @@
"resourceLearnRaw": "Learn how to configure TCP/UDP resources", "resourceLearnRaw": "Learn how to configure TCP/UDP resources",
"resourceBack": "Back to Resources", "resourceBack": "Back to Resources",
"resourceGoTo": "Go to Resource", "resourceGoTo": "Go to Resource",
"resourcePolicyDelete": "Delete Resource Policy",
"resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy",
"resourceDelete": "Delete Resource", "resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource",
"visibility": "Visibility", "visibility": "Visibility",
@@ -261,6 +285,8 @@
"rules": "Rules", "rules": "Rules",
"resourceSettingDescription": "Configure the settings on the resource", "resourceSettingDescription": "Configure the settings on the resource",
"resourceSetting": "{resourceName} Settings", "resourceSetting": "{resourceName} Settings",
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
"resourcePolicySetting": "{policyName} Settings",
"alwaysAllow": "Bypass Auth", "alwaysAllow": "Bypass Auth",
"alwaysDeny": "Block Access", "alwaysDeny": "Block Access",
"passToAuth": "Pass to Auth", "passToAuth": "Pass to Auth",
@@ -731,6 +757,16 @@
"rulesNoOne": "No rules. Add a rule using the form.", "rulesNoOne": "No rules. Add a rule using the form.",
"rulesOrder": "Rules are evaluated by priority in ascending order.", "rulesOrder": "Rules are evaluated by priority in ascending order.",
"rulesSubmit": "Save Rules", "rulesSubmit": "Save Rules",
"policyErrorCreate": "Error creating policy",
"policyErrorCreateDescription": "An error occurred when creating the policy",
"policyErrorCreateMessageDescription": "An unexpected error occurred",
"policyErrorUpdate": "Error updating policy",
"policyErrorUpdateDescription": "An error occurred when updating the policy",
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
"policyCreatedSuccess": "Resource policy succesfully created",
"policyUpdatedSuccess": "Resource policy succesfully updated",
"authMethodsSave": "Save auth methods",
"rulesSave": "Save Rules",
"resourceErrorCreate": "Error creating resource", "resourceErrorCreate": "Error creating resource",
"resourceErrorCreateDescription": "An error occurred when creating the resource", "resourceErrorCreateDescription": "An error occurred when creating the resource",
"resourceErrorCreateMessage": "Error creating resource:", "resourceErrorCreateMessage": "Error creating resource:",
@@ -794,6 +830,16 @@
"pincodeAdd": "Add PIN Code", "pincodeAdd": "Add PIN Code",
"pincodeRemove": "Remove PIN Code", "pincodeRemove": "Remove PIN Code",
"resourceAuthMethods": "Authentication Methods", "resourceAuthMethods": "Authentication Methods",
"resourcePolicyAuthMethodsEmpty": "No authentication method",
"resourcePolicyOtpEmpty": "No one time password",
"resourcePolicyReadOnly": "This policy is Read only",
"resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.",
"resourcePolicyTypeSave": "Save Resource type",
"resourcePolicySelect": "Select resource policy",
"resourcePolicySelectError": "Select a resource policy",
"resourcePolicyNotFound": "Policy not found",
"resourcePolicySearch": "Search policies",
"resourcePolicyRulesEmpty": "No authentication rules",
"resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods",
"resourceAuthSettingsSave": "Saved successfully", "resourceAuthSettingsSave": "Saved successfully",
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved", "resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
@@ -829,6 +875,12 @@
"resourcePincodeSetupTitle": "Set Pincode", "resourcePincodeSetupTitle": "Set Pincode",
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
"resourceRoleDescription": "Admins can always access this resource.", "resourceRoleDescription": "Admins can always access this resource.",
"resourcePolicySelectTitle": "Resource Access Policy",
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
"resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource Policy",
"resourcePolicySharedDescription": "Access Policy shared accross multiple resources",
"resourceUsersRoles": "Access Controls", "resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls", "resourceUsersRolesSubmit": "Save Access Controls",
@@ -1358,6 +1410,8 @@
"sidebarResources": "Resources", "sidebarResources": "Resources",
"sidebarProxyResources": "Public", "sidebarProxyResources": "Public",
"sidebarClientResources": "Private", "sidebarClientResources": "Private",
"sidebarPolicies": "Policies",
"sidebarResourcePolicies": "Resources",
"sidebarAccessControl": "Access Control", "sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarTeam": "Team", "sidebarTeam": "Team",
@@ -2660,19 +2714,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Reason", "reason": "Reason",
"requestLogs": "HTTP Request Logs", "requestLogs": "HTTPS Request Logs",
"requestAnalytics": "Request Analytics", "requestAnalytics": "Request Analytics",
"host": "Host", "host": "Host",
"location": "Location", "location": "Location",
"actionLogs": "Admin Action Logs", "actionLogs": "Admin Action Logs",
"sidebarLogsRequest": "HTTP Request Logs", "sidebarLogsRequest": "HTTPS Request Logs",
"sidebarLogsAccess": "Authentication Logs", "sidebarLogsAccess": "Authentication Logs",
"sidebarLogsAction": "Admin Action Logs", "sidebarLogsAction": "Admin Action Logs",
"logRetention": "Log Retention", "logRetention": "Log Retention",
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization", "requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
"logRetentionRequestLabel": "HTTP Request Log Retention", "logRetentionRequestLabel": "HTTPS Request Log Retention",
"logRetentionRequestDescription": "How long to retain request logs", "logRetentionRequestDescription": "How long to retain request logs",
"logRetentionAccessLabel": "Authentication Log Retention", "logRetentionAccessLabel": "Authentication Log Retention",
"logRetentionAccessDescription": "How long to retain access logs", "logRetentionAccessDescription": "How long to retain access logs",
@@ -3134,7 +3188,7 @@
"httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.",
"httpDestConnectionLogsTitle": "Network Logs", "httpDestConnectionLogsTitle": "Network Logs",
"httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.",
"httpDestRequestLogsTitle": "HTTP Request Logs", "httpDestRequestLogsTitle": "HTTPS Request Logs",
"httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.",
"httpDestSaveChanges": "Save Changes", "httpDestSaveChanges": "Save Changes",
"httpDestCreateDestination": "Create Destination", "httpDestCreateDestination": "Create Destination",
@@ -3208,48 +3262,5 @@
"domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.",
"domainPickerWildcardCertWarningLink": "Learn more", "domainPickerWildcardCertWarningLink": "Learn more",
"health": "Health", "health": "Health",
"domainPendingErrorTitle": "Verification Issue", "domainPendingErrorTitle": "Verification Issue"
"memberPortalTitle": "Resources",
"memberPortalDescription": "Resources you have access to in this organization",
"memberPortalSortBy": "Sort by...",
"memberPortalSortNameAsc": "Name A-Z",
"memberPortalSortNameDesc": "Name Z-A",
"memberPortalSortDomainAsc": "Domain A-Z",
"memberPortalSortDomainDesc": "Domain Z-A",
"memberPortalSortEnabledFirst": "Enabled First",
"memberPortalSortDisabledFirst": "Disabled First",
"memberPortalRefresh": "Refresh",
"memberPortalRefreshResources": "Refresh Resources",
"memberPortalFailedToLoad": "Failed to load resources",
"memberPortalFailedToLoadDescription": "Failed to load resources. Please check your connection and try again.",
"memberPortalUnableToLoad": "Unable to Load Resources",
"memberPortalTryAgain": "Try Again",
"memberPortalNoResourcesFound": "No Resources Found",
"memberPortalNoResourcesAvailable": "No Resources Available",
"memberPortalNoResourcesMatchSearch": "No resources match \"{query}\". Try adjusting your search terms or clearing the search to see all resources.",
"memberPortalNoResourcesAccess": "You don't have access to any resources yet. Contact your administrator to get access to resources you need.",
"memberPortalClearSearch": "Clear Search",
"memberPortalPublicResources": "Public Resources",
"memberPortalPublicResourcesDescription": "Web applications and services accessible via browser",
"memberPortalCopiedToClipboard": "Copied to clipboard",
"memberPortalCopiedUrlDescription": "Resource URL has been copied to your clipboard.",
"memberPortalOpenResource": "Open Resource",
"memberPortalPrivateResources": "Private Resources",
"memberPortalPrivateResourcesDescription": "Internal network resources accessible via client",
"memberPortalResourceDetails": "Resource Details",
"memberPortalMode": "Mode",
"memberPortalDestination": "Destination",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "Resource alias has been copied to your clipboard.",
"memberPortalCopiedDestinationDescription": "Resource destination has been copied to your clipboard.",
"memberPortalRequiresClientConnection": "Requires Client Connection",
"memberPortalAuthMethods": "Authentication Methods",
"memberPortalSso": "Single Sign-On (SSO)",
"memberPortalPasswordProtected": "Password Protected",
"memberPortalPinCode": "PIN Code",
"memberPortalEmailWhitelist": "Email Whitelist",
"memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"memberPortalPrevious": "Previous",
"memberPortalNext": "Next"
} }

View File

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

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Raison", "reason": "Raison",
"requestLogs": "Journal des Requêtes HTTP", "requestLogs": "Journal des requêtes",
"requestAnalytics": "Demander des analyses", "requestAnalytics": "Demander des analyses",
"host": "Hôte", "host": "Hôte",
"location": "Localisation", "location": "Localisation",
"actionLogs": "Journaux des actions", "actionLogs": "Journaux des actions",
"sidebarLogsRequest": "Journal des Requêtes HTTP", "sidebarLogsRequest": "Journal des requêtes",
"sidebarLogsAccess": "Journaux d'accès", "sidebarLogsAccess": "Journaux d'accès",
"sidebarLogsAction": "Journaux des actions", "sidebarLogsAction": "Journaux des actions",
"logRetention": "Journaliser la rétention", "logRetention": "Journaliser la rétention",
"logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver", "logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver",
"requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation", "requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation",
"requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation", "requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation",
"logRetentionRequestLabel": "Rétention des Journaux de Requêtes HTTP", "logRetentionRequestLabel": "Demander la rétention des journaux",
"logRetentionRequestDescription": "Durée de conservation des journaux de requêtes", "logRetentionRequestDescription": "Durée de conservation des journaux de requêtes",
"logRetentionAccessLabel": "Rétention du journal d'accès", "logRetentionAccessLabel": "Rétention du journal d'accès",
"logRetentionAccessDescription": "Durée de conservation des journaux d'accès", "logRetentionAccessDescription": "Durée de conservation des journaux d'accès",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.", "httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.",
"httpDestConnectionLogsTitle": "Journaux de connexion", "httpDestConnectionLogsTitle": "Journaux de connexion",
"httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.", "httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.",
"httpDestRequestLogsTitle": "Journal des Requêtes HTTP", "httpDestRequestLogsTitle": "Journal des requêtes",
"httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.", "httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.",
"httpDestSaveChanges": "Enregistrer les modifications", "httpDestSaveChanges": "Enregistrer les modifications",
"httpDestCreateDestination": "Créer une destination", "httpDestCreateDestination": "Créer une destination",
@@ -3209,48 +3209,5 @@
"domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.", "domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.",
"domainPickerWildcardCertWarningLink": "En savoir plus", "domainPickerWildcardCertWarningLink": "En savoir plus",
"health": "Santé", "health": "Santé",
"domainPendingErrorTitle": "Problème de vérification", "domainPendingErrorTitle": "Problème de vérification"
"memberPortalTitle": "Ressources",
"memberPortalDescription": "Ressources auxquelles vous avez accès dans cette organisation",
"memberPortalSortBy": "Trier par...",
"memberPortalSortNameAsc": "Nom A-Z",
"memberPortalSortNameDesc": "Nom Z-A",
"memberPortalSortDomainAsc": "Domaine A-Z",
"memberPortalSortDomainDesc": "Domaine Z-A",
"memberPortalSortEnabledFirst": "Activé en premier",
"memberPortalSortDisabledFirst": "Désactivé en premier",
"memberPortalRefresh": "Actualiser",
"memberPortalRefreshResources": "Actualiser les ressources",
"memberPortalFailedToLoad": "Échec du chargement des ressources",
"memberPortalFailedToLoadDescription": "Échec du chargement des ressources. Veuillez vérifier votre connexion et réessayer.",
"memberPortalUnableToLoad": "Impossible de charger les ressources",
"memberPortalTryAgain": "Réessayer",
"memberPortalNoResourcesFound": "Aucune ressource trouvée",
"memberPortalNoResourcesAvailable": "Aucune ressource disponible",
"memberPortalNoResourcesMatchSearch": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster vos termes de recherche ou de vider la recherche pour voir toutes les ressources.",
"memberPortalNoResourcesAccess": "Vous n'avez encore accès à aucune ressource. Contactez votre administrateur pour obtenir l'accès aux ressources dont vous avez besoin.",
"memberPortalClearSearch": "Effacer la recherche",
"memberPortalPublicResources": "Ressources publiques",
"memberPortalPublicResourcesDescription": "Applications et services web accessibles via un navigateur",
"memberPortalCopiedToClipboard": "Copié dans le presse-papiers",
"memberPortalCopiedUrlDescription": "L'URL de la ressource a été copiée dans votre presse-papiers.",
"memberPortalOpenResource": "Ouvrir la ressource",
"memberPortalPrivateResources": "Ressources privées",
"memberPortalPrivateResourcesDescription": "Ressources réseau internes accessibles via un client",
"memberPortalResourceDetails": "Détails de la ressource",
"memberPortalMode": "Mode",
"memberPortalDestination": "Destination",
"memberPortalAlias": "Alias",
"memberPortalCopiedAliasDescription": "L'alias de la ressource a été copié dans votre presse-papiers.",
"memberPortalCopiedDestinationDescription": "La destination de la ressource a été copiée dans votre presse-papiers.",
"memberPortalRequiresClientConnection": "Nécessite une connexion client",
"memberPortalAuthMethods": "Méthodes d'authentification",
"memberPortalSso": "Authentification unique (SSO)",
"memberPortalPasswordProtected": "Protégé par un mot de passe",
"memberPortalPinCode": "Code PIN",
"memberPortalEmailWhitelist": "Liste blanche des e-mails",
"memberPortalResourceDisabled": "Ressource désactivée",
"memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources",
"memberPortalPrevious": "Précédent",
"memberPortalNext": "Suivant"
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2660,19 +2660,19 @@
"noMoreAuthMethods": "No Valid Auth", "noMoreAuthMethods": "No Valid Auth",
"ip": "IP", "ip": "IP",
"reason": "Powód", "reason": "Powód",
"requestLogs": "Dzienniki żądań HTTP", "requestLogs": "Dzienniki żądań",
"requestAnalytics": "Żądanie Analityki", "requestAnalytics": "Żądanie Analityki",
"host": "Host", "host": "Host",
"location": "Lokalizacja", "location": "Lokalizacja",
"actionLogs": "Dzienniki działań", "actionLogs": "Dzienniki działań",
"sidebarLogsRequest": "Dzienniki żądań HTTP", "sidebarLogsRequest": "Dzienniki żądań",
"sidebarLogsAccess": "Logi dostępu", "sidebarLogsAccess": "Logi dostępu",
"sidebarLogsAction": "Dzienniki działań", "sidebarLogsAction": "Dzienniki działań",
"logRetention": "Zachowanie dziennika", "logRetention": "Zachowanie dziennika",
"logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je", "logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je",
"requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji", "requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji",
"requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji", "requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji",
"logRetentionRequestLabel": "Przechowywanie dzienników żądań HTTP", "logRetentionRequestLabel": "Zachowanie dziennika żądań",
"logRetentionRequestDescription": "Jak długo zachować dzienniki żądań", "logRetentionRequestDescription": "Jak długo zachować dzienniki żądań",
"logRetentionAccessLabel": "Zachowanie dziennika dostępu", "logRetentionAccessLabel": "Zachowanie dziennika dostępu",
"logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu", "logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu",
@@ -3134,7 +3134,7 @@
"httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.", "httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.",
"httpDestConnectionLogsTitle": "Dzienniki połączeń", "httpDestConnectionLogsTitle": "Dzienniki połączeń",
"httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.", "httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.",
"httpDestRequestLogsTitle": "Dzienniki żądań HTTP", "httpDestRequestLogsTitle": "Dzienniki żądań",
"httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.", "httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.",
"httpDestSaveChanges": "Zapisz zmiany", "httpDestSaveChanges": "Zapisz zmiany",
"httpDestCreateDestination": "Utwórz cel", "httpDestCreateDestination": "Utwórz cel",
@@ -3208,48 +3208,5 @@
"domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.", "domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.",
"domainPickerWildcardCertWarningLink": "Dowiedz się więcej", "domainPickerWildcardCertWarningLink": "Dowiedz się więcej",
"health": "Zdrowie", "health": "Zdrowie",
"domainPendingErrorTitle": "Problem z weryfikacją", "domainPendingErrorTitle": "Problem z weryfikacją"
"memberPortalTitle": "Zasoby",
"memberPortalDescription": "Zasoby, do których masz dostęp w tej organizacji",
"memberPortalSortBy": "Sortuj według...",
"memberPortalSortNameAsc": "Nazwa A-Z",
"memberPortalSortNameDesc": "Nazwa Z-A",
"memberPortalSortDomainAsc": "Domena A-Z",
"memberPortalSortDomainDesc": "Domena Z-A",
"memberPortalSortEnabledFirst": "Włączone najpierw",
"memberPortalSortDisabledFirst": "Wyłączone najpierw",
"memberPortalRefresh": "Odśwież",
"memberPortalRefreshResources": "Odśwież zasoby",
"memberPortalFailedToLoad": "Nie udało się załadować zasobów",
"memberPortalFailedToLoadDescription": "Nie udało się załadować zasobów. Sprawdź połączenie i spróbuj ponownie.",
"memberPortalUnableToLoad": "Nie można załadować zasobów",
"memberPortalTryAgain": "Spróbuj ponownie",
"memberPortalNoResourcesFound": "Nie znaleziono zasobów",
"memberPortalNoResourcesAvailable": "Brak dostępnych zasobów",
"memberPortalNoResourcesMatchSearch": "Żadne zasoby nie pasują do „{query}”. Spróbuj dostosować swoje warunki wyszukiwania lub wyczyść wyszukiwanie, aby zobaczyć wszystkie zasoby.",
"memberPortalNoResourcesAccess": "Nie masz jeszcze dostępu do żadnych zasobów. Skontaktuj się z administratorem, aby uzyskać dostęp do potrzebnych zasobów.",
"memberPortalClearSearch": "Wyczyść wyszukiwanie",
"memberPortalPublicResources": "Publiczne zasoby",
"memberPortalPublicResourcesDescription": "Aplikacje i usługi internetowe dostępne za pośrednictwem przeglądarki",
"memberPortalCopiedToClipboard": "Skopiowano do schowka",
"memberPortalCopiedUrlDescription": "URL zasobu został skopiowany do schowka.",
"memberPortalOpenResource": "Otwórz zasób",
"memberPortalPrivateResources": "Prywatne zasoby",
"memberPortalPrivateResourcesDescription": "Zasoby sieci wewnętrznej dostępne za pośrednictwem klienta",
"memberPortalResourceDetails": "Szczegóły zasobu",
"memberPortalMode": "Tryb",
"memberPortalDestination": "Miejsce docelowe",
"memberPortalAlias": "Pseudonim",
"memberPortalCopiedAliasDescription": "Alias zasobu został skopiowany do schowka.",
"memberPortalCopiedDestinationDescription": "Miejsce docelowe zasobu zostało skopiowane do schowka.",
"memberPortalRequiresClientConnection": "Wymaga połączenia z klientem",
"memberPortalAuthMethods": "Metody uwierzytelniania",
"memberPortalSso": "Jednorazowe logowanie (SSO)",
"memberPortalPasswordProtected": "Chronione hasłem",
"memberPortalPinCode": "Kod PIN",
"memberPortalEmailWhitelist": "Biała lista e-mail",
"memberPortalResourceDisabled": "Zasób wyłączony",
"memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}",
"memberPortalPrevious": "Poprzedni",
"memberPortalNext": "Następny"
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import logger from "@server/logger";
export enum ActionsEnum { export enum ActionsEnum {
createOrgUser = "createOrgUser", createOrgUser = "createOrgUser",
@@ -152,7 +153,21 @@ export enum ActionsEnum {
createHealthCheck = "createHealthCheck", createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck", updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck", deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks" listHealthChecks = "listHealthChecks",
listResourcePolicies = "listResourcePolicies",
getResourcePolicy = "getResourcePolicy",
createResourcePolicy = "createResourcePolicy",
updateResourcePolicy = "updateResourcePolicy",
deleteResourcePolicy = "deleteResourcePolicy",
listResourcePolicyRoles = "listResourcePolicyRoles",
setResourcePolicyRoles = "setResourcePolicyRoles",
listResourcePolicyUsers = "listResourcePolicyUsers",
setResourcePolicyUsers = "setResourcePolicyUsers",
setResourcePolicyPassword = "setResourcePolicyPassword",
setResourcePolicyPincode = "setResourcePolicyPincode",
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(
@@ -185,6 +200,23 @@ export async function checkUserActionPermission(
} }
} }
// If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
.limit(1);
if (roleActionPermission.length > 0) {
return true;
}
// Check if the user has direct permission for the action in the current org // Check if the user has direct permission for the action in the current org
const userActionPermission = await db const userActionPermission = await db
.select() .select()
@@ -202,20 +234,7 @@ export async function checkUserActionPermission(
return true; return true;
} }
// If no direct permission, check role-based permission (any of user's roles) return false;
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
.limit(1);
return roleActionPermission.length > 0;
} catch (error) { } catch (error) {
console.error("Error checking user action permission:", error); console.error("Error checking user action permission:", error);
throw createHttpError( throw createHttpError(

View File

@@ -1,6 +1,12 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { clients, db, resources, siteResources } from "@server/db"; import {
clients,
db,
resourcePolicies,
resources,
siteResources
} from "@server/db";
import { randomInt } from "crypto"; import { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db"; import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
@@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
} }
} }
export async function getUniqueResourcePolicyName(
orgId: string
): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const policyCount = await db
.select({
niceId: resourcePolicies.niceId,
orgId: resourcePolicies.orgId
})
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, name),
eq(resourcePolicies.orgId, orgId)
)
);
if (policyCount.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueSiteResourceName( export async function getUniqueSiteResourceName(
orgId: string orgId: string
): Promise<string> { ): Promise<string> {

View File

@@ -110,6 +110,16 @@ export const sites = pgTable("sites", {
export const resources = pgTable("resources", { export const resources = pgTable("resources", {
resourceId: serial("resourceId").primaryKey(), resourceId: serial("resourceId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: varchar("resourceGuid", { length: 36 }) resourceGuid: varchar("resourceGuid", { length: 36 })
.unique() .unique()
.notNull() .notNull()
@@ -196,9 +206,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId")
onDelete: "cascade" .references(() => sites.siteId, {
}).notNull(), onDelete: "cascade"
})
.notNull(),
name: varchar("name"), name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false), hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"), hcPath: varchar("hcPath"),
@@ -521,6 +533,38 @@ export const userResources = pgTable("userResources", {
.references(() => resources.resourceId, { onDelete: "cascade" }) .references(() => resources.resourceId, { onDelete: "cascade" })
}); });
export const rolePolicies = pgTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = pgTable("userPolicies", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", {
whitelistId: serial("id").primaryKey(),
email: varchar("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userInvites = pgTable("userInvites", { export const userInvites = pgTable("userInvites", {
inviteId: varchar("inviteId").primaryKey(), inviteId: varchar("inviteId").primaryKey(),
orgId: varchar("orgId") orgId: varchar("orgId")
@@ -586,6 +630,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable(
} }
); );
export const resourcePolicyPincode = pgTable("resourcePolicyPincode", {
pincodeId: serial("pincodeId").primaryKey(),
pincodeHash: varchar("pincodeHash").notNull(),
digitLength: integer("digitLength").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyPassword = pgTable("resourcePolicyPassword", {
passwordId: serial("passwordId").primaryKey(),
passwordHash: varchar("passwordHash").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", {
headerAuthId: serial("headerAuthId").primaryKey(),
headerAuthHash: varchar("headerAuthHash").notNull(),
extendedCompatibility: boolean("extendedCompatibility")
.notNull()
.default(true),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourceAccessToken = pgTable("resourceAccessToken", { export const resourceAccessToken = pgTable("resourceAccessToken", {
accessTokenId: varchar("accessTokenId").primaryKey(), accessTokenId: varchar("accessTokenId").primaryKey(),
orgId: varchar("orgId") orgId: varchar("orgId")
@@ -679,6 +757,43 @@ export const resourceRules = pgTable("resourceRules", {
value: varchar("value").notNull() value: varchar("value").notNull()
}); });
export const resourcePolicyRules = pgTable("resourcePolicyRules", {
ruleId: serial("ruleId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
value: varchar("value").notNull()
});
export const resourcePolicies = pgTable("resourcePolicies", {
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
sso: boolean("sso").notNull().default(true),
applyRules: boolean("applyRules").notNull().default(false),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const supporterKey = pgTable("supporterKey", { export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(), keyId: serial("keyId").primaryKey(),
key: varchar("key").notNull(), key: varchar("key").notNull(),
@@ -1097,19 +1212,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false) complete: boolean("complete").notNull().default(false)
}); });
export const statusHistory = pgTable("statusHistory", { export const statusHistory = pgTable(
id: serial("id").primaryKey(), "statusHistory",
entityType: varchar("entityType").notNull(), {
entityId: integer("entityId").notNull(), id: serial("id").primaryKey(),
orgId: varchar("orgId") entityType: varchar("entityType").notNull(),
.notNull() entityId: integer("entityId").notNull(),
.references(() => orgs.orgId, { onDelete: "cascade" }), orgId: varchar("orgId")
status: varchar("status").notNull(), .notNull()
timestamp: integer("timestamp").notNull(), .references(() => orgs.orgId, { onDelete: "cascade" }),
}, (table) => [ status: varchar("status").notNull(),
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), timestamp: integer("timestamp").notNull()
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), },
]); (table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@@ -1179,3 +1305,6 @@ export type RoundTripMessageTracker = InferSelectModel<
>; >;
export type Network = InferSelectModel<typeof networks>; export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -17,10 +17,13 @@ import {
resourceHeaderAuth, resourceHeaderAuth,
ResourceHeaderAuth, ResourceHeaderAuth,
resourceRules, resourceRules,
resourcePolicyRules,
resources, resources,
roleResources, roleResources,
rolePolicies,
sessions, sessions,
userResources, userResources,
userPolicies,
users, users,
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility resourceHeaderAuthExtendedCompatibility
@@ -154,58 +157,126 @@ export async function getRoleName(roleId: number): Promise<string | null> {
} }
/** /**
* Check if role has access to resource * Check if role has access to resource (direct or via resource policy)
*/ */
export async function getRoleResourceAccess( export async function getRoleResourceAccess(
resourceId: number, resourceId: number,
roleIds: number[] roleIds: number[]
) { ) {
const roleResourceAccess = await db const [direct, viaPolicies] = await Promise.all([
.select() db
.from(roleResources) .select()
.where( .from(roleResources)
and( .where(
eq(roleResources.resourceId, resourceId), and(
inArray(roleResources.roleId, roleIds) eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
)
),
db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
eq(resources.resourcePolicyId, rolePolicies.resourcePolicyId)
) )
); .where(
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
]);
return roleResourceAccess.length > 0 ? roleResourceAccess : null; const combined = [...direct, ...viaPolicies];
return combined.length > 0 ? combined : null;
} }
/** /**
* Check if user has direct access to resource * Check if user has access to resource (direct or via resource policy)
*/ */
export async function getUserResourceAccess( export async function getUserResourceAccess(
userId: string, userId: string,
resourceId: number resourceId: number
) { ) {
const userResourceAccess = await db const [direct, viaPolicies] = await Promise.all([
.select() db
.from(userResources) .select()
.where( .from(userResources)
and( .where(
eq(userResources.userId, userId), and(
eq(userResources.resourceId, resourceId) eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
) )
) .limit(1),
.limit(1); db
.select({
userId: userPolicies.userId,
resourcePolicyId: userPolicies.resourcePolicyId
})
.from(userPolicies)
.innerJoin(
resources,
eq(resources.resourcePolicyId, userPolicies.resourcePolicyId)
)
.where(
and(
eq(resources.resourceId, resourceId),
eq(userPolicies.userId, userId)
)
)
.limit(1)
]);
return userResourceAccess.length > 0 ? userResourceAccess[0] : null; return direct[0] ?? viaPolicies[0] ?? null;
} }
/** /**
* Get resource rules for a given resource * Get resource rules for a given resource (direct and via resource policy)
*/ */
export async function getResourceRules( export async function getResourceRules(
resourceId: number resourceId: number
): Promise<ResourceRule[]> { ): Promise<ResourceRule[]> {
const rules = await db const [directRules, policyRules] = await Promise.all([
.select() db
.from(resourceRules) .select()
.where(eq(resourceRules.resourceId, resourceId)); .from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId)),
db
.select({
ruleId: resourcePolicyRules.ruleId,
resourceId: sql<number>`${resourceId}`,
enabled: resourcePolicyRules.enabled,
priority: resourcePolicyRules.priority,
action: resourcePolicyRules.action,
match: resourcePolicyRules.match,
value: resourcePolicyRules.value
})
.from(resourcePolicyRules)
.innerJoin(
resources,
eq(
resources.resourcePolicyId,
resourcePolicyRules.resourcePolicyId
)
)
.where(eq(resources.resourceId, resourceId))
]);
return rules; const maxDirectPriority = directRules.reduce(
(max, r) => Math.max(max, r.priority),
0
);
const offsetPolicyRules = policyRules.map((r) => ({
...r,
priority: maxDirectPriority + r.priority
}));
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
} }
/** /**

View File

@@ -121,6 +121,16 @@ export const sites = sqliteTable("sites", {
export const resources = sqliteTable("resources", { export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: text("resourceGuid", { length: 36 }) resourceGuid: text("resourceGuid", { length: 36 })
.unique() .unique()
.notNull() .notNull()
@@ -219,9 +229,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId")
onDelete: "cascade" .references(() => sites.siteId, {
}).notNull(), onDelete: "cascade"
})
.notNull(),
name: text("name"), name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" }) hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull() .notNull()
@@ -909,6 +921,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull() headerAuthHash: text("headerAuthHash").notNull()
}); });
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
pincodeHash: text("pincodeHash").notNull(),
digitLength: integer("digitLength").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
passwordHash: text("passwordHash").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyHeaderAuth = sqliteTable(
"resourcePolicyHeaderAuth",
{
headerAuthId: integer("headerAuthId").primaryKey({
autoIncrement: true
}),
headerAuthHash: text("headerAuthHash").notNull(),
extendedCompatibility: integer("extendedCompatibility", {
mode: "boolean"
})
.notNull()
.default(true),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
}
);
export const resourceHeaderAuthExtendedCompatibility = sqliteTable( export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
"resourceHeaderAuthExtendedCompatibility", "resourceHeaderAuthExtendedCompatibility",
{ {
@@ -1023,6 +1076,77 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull() value: text("value").notNull()
}); });
export const rolePolicies = sqliteTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = sqliteTable("userPolicies", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
value: text("value").notNull()
});
export const resourcePolicies = sqliteTable("resourcePolicies", {
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
applyRules: integer("applyRules", { mode: "boolean" })
.notNull()
.default(false),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false),
niceId: text("niceId").notNull(),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
name: text("name").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const supporterKey = sqliteTable("supporterKey", { export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }), keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(), key: text("key").notNull(),
@@ -1196,19 +1320,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false) complete: integer("complete", { mode: "boolean" }).notNull().default(false)
}); });
export const statusHistory = sqliteTable("statusHistory", { export const statusHistory = sqliteTable(
id: integer("id").primaryKey({ autoIncrement: true }), "statusHistory",
entityType: text("entityType").notNull(), // "site" | "healthCheck" {
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId") entityType: text("entityType").notNull(), // "site" | "healthCheck"
.notNull() entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
.references(() => orgs.orgId, { onDelete: "cascade" }), orgId: text("orgId")
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks .notNull()
timestamp: integer("timestamp").notNull(), // unix epoch seconds .references(() => orgs.orgId, { onDelete: "cascade" }),
}, (table) => [ status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), timestamp: integer("timestamp").notNull() // unix epoch seconds
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), },
]); (table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@@ -1278,3 +1413,6 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -24,7 +24,8 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain" WildcardSubdomain = "wildcardSubdomain",
ResourcePolicies = "resourcePolicies"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -66,5 +67,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"] [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"]
}; };

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -162,9 +162,10 @@ export const HeaderSchema = z.object({
}); });
// Schema for individual resource // Schema for individual resource
export const ResourceSchema = z export const PublicResourceSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
policy: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(), protocol: z.enum(["http", "tcp", "udp"]).optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
@@ -340,7 +341,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/; const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label)); return parts.slice(1).every((label) => labelRegex.test(label));
}, },
{ {
@@ -354,7 +356,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets; return Object.keys(resource).length === 1 && resource.targets;
} }
export const ClientResourceSchema = z export const PrivateResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr", "http"]),
@@ -435,19 +437,19 @@ export const ClientResourceSchema = z
export const ConfigSchema = z export const ConfigSchema = z
.object({ .object({
"proxy-resources": z "proxy-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"public-resources": z "public-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"client-resources": z "client-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"private-resources": z "private-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({}) sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -472,10 +474,13 @@ export const ConfigSchema = z
} }
return data as { return data as {
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>; "proxy-resources": Record<
string,
z.infer<typeof PublicResourceSchema>
>;
"client-resources": Record< "client-resources": Record<
string, string,
z.infer<typeof ClientResourceSchema> z.infer<typeof PrivateResourceSchema>
>; >;
sites: Record<string, z.infer<typeof SiteSchema>>; sites: Record<string, z.infer<typeof SiteSchema>>;
}; };
@@ -614,5 +619,5 @@ export const ConfigSchema = z
// Type inference from the schema // Type inference from the schema
export type Site = z.infer<typeof SiteSchema>; export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>; export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof ResourceSchema>; export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

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

View File

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

View File

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

View File

@@ -32,3 +32,4 @@ export * from "./verifySiteResourceAccess";
export * from "./logActionAudit"; export * from "./logActionAudit";
export * from "./verifyOlmAccess"; export * from "./verifyOlmAccess";
export * from "./verifyLimits"; export * from "./verifyLimits";
export * from "./verifyResourcePolicyAccess";

View File

@@ -16,3 +16,4 @@ export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyResourcePolicyAccess";

View File

@@ -0,0 +1,92 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resourcePolicies, apiKeyOrg } from "@server/db";
import { eq, and } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeyResourcePolicyAccess(
req: Request,
res: Response,
next: NextFunction
) {
const apiKey = req.apiKey;
const resourcePolicyId =
req.params.resourcePolicyId ||
req.body.resourcePolicyId ||
req.query.resourcePolicyId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
try {
// Retrieve the resource policy
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${resourcePolicyId} not found`
)
);
}
if (apiKey.isRoot) {
// Root keys can access any resource policy in any org
return next();
}
if (!policy.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource policy with ID ${resourcePolicyId} does not have an organization ID`
)
);
}
// Verify that the API key is linked to the resource policy's organization
if (!req.apiKeyOrg) {
const apiKeyOrgResult = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, policy.orgId)
)
)
.limit(1);
if (apiKeyOrgResult.length > 0) {
req.apiKeyOrg = apiKeyOrgResult[0];
}
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying resource policy access"
)
);
}
}

View File

@@ -0,0 +1,127 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resourcePolicies, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyResourcePolicyAccess(
req: Request,
res: Response,
next: NextFunction
) {
const userId = req.user!.userId;
const resourcePolicyIdStr =
req.params?.resourcePolicyId ||
req.body?.resourcePolicyId ||
req.query?.resourcePolicyId;
const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
try {
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
let policy: typeof resourcePolicies.$inferSelect | null = null;
if (orgId && niceId) {
const [policyRes] = await db
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, niceId),
eq(resourcePolicies.orgId, orgId)
)
)
.limit(1);
policy = policyRes ?? null;
} else {
const resourcePolicyId = parseInt(resourcePolicyIdStr);
if (isNaN(resourcePolicyId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid resource policy ID"
)
);
}
const [policyRes] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
policy = policyRes ?? null;
}
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found`
)
);
}
if (!req.userOrg) {
const userOrgRes = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, policy.orgId)
)
)
.limit(1);
req.userOrg = userOrgRes[0];
}
if (!req.userOrg || req.userOrg.orgId !== policy.orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
policy.orgId
);
req.userOrgId = policy.orgId;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying resource policy access"
)
);
}
}

View File

@@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have permission perform this action" "User does not have permission to set user organization roles"
) )
); );
} catch (error) { } catch (error) {

View File

@@ -7,6 +7,7 @@ export enum OpenAPITags {
Org = "Organization", Org = "Organization",
PublicResource = "Public Resource", PublicResource = "Public Resource",
PrivateResource = "Private Resource", PrivateResource = "Private Resource",
Policy = "Policy",
Role = "Role", Role = "Role",
User = "User", User = "User",
Invitation = "User Invitation", Invitation = "User Invitation",

View File

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

View File

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

View File

@@ -31,6 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as resource from "#private/routers/resource";
import * as policy from "#private/routers/policy";
import { import {
verifyOrgAccess, verifyOrgAccess,
@@ -44,7 +46,8 @@ import {
verifyUserCanSetUserOrgRoles, verifyUserCanSetUserOrgRoles,
verifySiteProvisioningKeyAccess, verifySiteProvisioningKeyAccess,
verifyIsLoggedInUser, verifyIsLoggedInUser,
verifyAdmin verifyAdmin,
verifyResourcePolicyAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { import {
@@ -382,6 +385,39 @@ authenticated.get(
approval.countApprovals approval.countApprovals
); );
authenticated.delete(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyValidLicense,
verifyValidSubscription(tierMatrix.resourcePolicies),
verifyLimits,
verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
logActionAudit(ActionsEnum.deleteResourcePolicy),
policy.deleteResourcePolicy
);
authenticated.get(
"/org/:orgId/resource-policies",
verifyValidLicense,
verifyValidSubscription(tierMatrix.resourcePolicies),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.listResourcePolicies),
logActionAudit(ActionsEnum.listResourcePolicies),
policy.listResourcePolicies
);
authenticated.post(
"/org/:orgId/resource-policy",
verifyValidLicense,
verifyValidSubscription(tierMatrix.resourcePolicies),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createResourcePolicy),
logActionAudit(ActionsEnum.createResourcePolicy),
policy.createResourcePolicy
);
authenticated.put( authenticated.put(
"/org/:orgId/approvals/:approvalId", "/org/:orgId/approvals/:approvalId",
verifyValidLicense, verifyValidLicense,

View File

@@ -45,8 +45,11 @@ import {
users, users,
userOrgs, userOrgs,
roleResources, roleResources,
rolePolicies,
userResources, userResources,
userPolicies,
resourceRules, resourceRules,
resourcePolicyRules,
userOrgRoles, userOrgRoles,
roles roles
} from "@server/db"; } from "@server/db";
@@ -430,7 +433,10 @@ hybridRouter.get(
); );
// Decrypt and save key file // Decrypt and save key file
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!); const decryptedKey = decrypt(
cert.keyFile!,
config.getRawConfig().server.secret!
);
// Return only the certificate data without org information // Return only the certificate data without org information
return { return {
@@ -531,7 +537,10 @@ hybridRouter.get(
wildcardCandidates.length > 0 wildcardCandidates.length > 0
? and( ? and(
eq(resources.wildcard, true), eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates) inArray(
resources.fullDomain,
wildcardCandidates
)
) )
: sql`false` : sql`false`
) )
@@ -545,10 +554,10 @@ hybridRouter.get(
if ( if (
result && result &&
await checkExitNodeOrg( (await checkExitNodeOrg(
remoteExitNode.exitNodeId, remoteExitNode.exitNodeId,
result.resources.orgId result.resources.orgId
) ))
) { ) {
// If the exit node is not allowed for the org, return an error // If the exit node is not allowed for the org, return an error
return next( return next(
@@ -1132,22 +1141,43 @@ hybridRouter.get(
); );
} }
const roleResourceAccess = await db const [direct, viaPolicies] = await Promise.all([
.select() db
.from(roleResources) .select()
.where( .from(roleResources)
and( .where(
eq(roleResources.resourceId, resourceId), and(
eq(roleResources.roleId, roleId) eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
) )
) .limit(1),
.limit(1); db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
eq(
resources.resourcePolicyId,
rolePolicies.resourcePolicyId
)
)
.where(
and(
eq(resources.resourceId, resourceId),
eq(rolePolicies.roleId, roleId)
)
)
.limit(1)
]);
const result = const result = direct[0] ?? viaPolicies[0] ?? null;
roleResourceAccess.length > 0 ? roleResourceAccess[0] : null;
return response<typeof roleResources.$inferSelect | null>(res, { return response<typeof roleResources.$inferSelect | null>(res, {
data: result, data: result as any,
success: true, success: true,
error: false, error: false,
message: result message: result
@@ -1222,21 +1252,44 @@ hybridRouter.get(
); );
} }
const roleResourceAccess = await db const [direct, viaPolicies] = await Promise.all([
.select({ db
resourceId: roleResources.resourceId, .select({
roleId: roleResources.roleId resourceId: roleResources.resourceId,
}) roleId: roleResources.roleId
.from(roleResources) })
.where( .from(roleResources)
and( .where(
eq(roleResources.resourceId, resourceId), and(
inArray(roleResources.roleId, roleIds) eq(roleResources.resourceId, resourceId),
) inArray(roleResources.roleId, roleIds)
); )
),
roleIds.length > 0
? db
.select({
resourceId: sql<number>`${resourceId}`,
roleId: rolePolicies.roleId
})
.from(rolePolicies)
.innerJoin(
resources,
eq(
resources.resourcePolicyId,
rolePolicies.resourcePolicyId
)
)
.where(
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
: Promise.resolve([])
]);
const result = const combined = [...direct, ...viaPolicies];
roleResourceAccess.length > 0 ? roleResourceAccess : null; const result = combined.length > 0 ? combined : null;
return response<{ resourceId: number; roleId: number }[] | null>( return response<{ resourceId: number; roleId: number }[] | null>(
res, res,
@@ -1397,10 +1450,45 @@ hybridRouter.get(
); );
} }
const rules = await db const [directRules, policyRules] = await Promise.all([
.select() db
.from(resourceRules) .select()
.where(eq(resourceRules.resourceId, resourceId)); .from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId)),
db
.select({
ruleId: resourcePolicyRules.ruleId,
resourceId: sql<number>`${resourceId}`,
enabled: resourcePolicyRules.enabled,
priority: resourcePolicyRules.priority,
action: resourcePolicyRules.action,
match: resourcePolicyRules.match,
value: resourcePolicyRules.value
})
.from(resourcePolicyRules)
.innerJoin(
resources,
eq(
resources.resourcePolicyId,
resourcePolicyRules.resourcePolicyId
)
)
.where(eq(resources.resourceId, resourceId))
]);
const maxDirectPriority = directRules.reduce(
(max, r) => Math.max(max, r.priority),
0
);
const offsetPolicyRules = policyRules.map((r) => ({
...r,
priority: maxDirectPriority + r.priority
}));
const rules = [
...directRules,
...offsetPolicyRules
] as (typeof resourceRules.$inferSelect)[];
// backward compatibility: COUNTRY -> GEOIP // backward compatibility: COUNTRY -> GEOIP
// TODO: remove this after a few versions once all exit nodes are updated // TODO: remove this after a few versions once all exit nodes are updated

View File

@@ -0,0 +1,417 @@
/*
* 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 { hashPassword } from "@server/auth/password";
import {
db,
idp,
idpOrg,
orgs,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
roles,
userOrgs,
userPolicies,
users,
type ResourcePolicy
} from "@server/db";
import { getUniqueResourcePolicyName } from "@server/db/names";
import response from "@server/lib/response";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray, type InferInsertModel } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import z from "zod";
import { fromError } from "zod-validation-error";
const createResourcePolicyParamsSchema = z.strictObject({
orgId: z.string()
});
const ruleSchema = z.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
type: "string",
enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action"
}),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
type: "string",
enum: ["CIDR", "IP", "PATH"],
description: "rule match"
}),
value: z.string().min(1),
priority: z.int().openapi({
type: "integer",
description: "Rule priority"
}),
enabled: z.boolean().optional()
});
const createResourcePolicyBodySchema = z.strictObject({
name: z.string().min(1).max(255),
// Access control
sso: z.boolean().default(true),
skipToIdpId: z
.int()
.positive()
.optional()
.nullable()
.openapi({ type: "integer" }),
roleIds: z
.array(z.string().transform(Number).pipe(z.int().positive()))
.optional()
.default([]),
userIds: z.array(z.string()).optional().default([]),
// auth methods
password: z.string().min(4).max(100).nullable().optional(),
pincode: z
.string()
.regex(/^\d{6}$/)
.or(z.null())
.optional(),
headerAuth: z
.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100),
extendedCompatibility: z.boolean()
})
.nullable()
.optional(),
// email OTP
emailWhitelistEnabled: z.boolean().optional().default(false),
emails: z
.array(
z.email().or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
error: "Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
// rules
applyRules: z.boolean().default(false),
rules: z.array(ruleSchema).optional().default([])
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/resource-policy",
description: "Create a resource policy.",
tags: [OpenAPITags.Org, OpenAPITags.Policy],
request: {
params: createResourcePolicyParamsSchema,
body: {
content: {
"application/json": {
schema: createResourcePolicyBodySchema
}
}
}
},
responses: {}
});
export async function createResourcePolicy(
req: Request,
res: Response,
next: NextFunction
) {
try {
// Validate request params
const parsedParams = createResourcePolicyParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (req.user && req.userOrgRoleIds?.length === 0) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
}
// get the org
const org = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (org.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
const parsedBody = createResourcePolicyBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const {
name,
sso,
userIds,
roleIds,
skipToIdpId,
applyRules,
emailWhitelistEnabled,
password,
pincode,
headerAuth,
emails,
rules
} = parsedBody.data;
// Check if Identity provider in `skipToIdpId` exists
if (skipToIdpId) {
const [provider] = await db
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId)))
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Identity provider not found in this organization"
)
);
}
}
const adminRole = await db
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
const existingRoles = await db
.select()
.from(roles)
.where(and(inArray(roles.roleId, roleIds)));
const hasAdminRole = existingRoles.some((role) => role.isAdmin);
if (hasAdminRole) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Admin role cannot be assigned to resource policy"
)
);
}
const existingUsers = await db
.select()
.from(users)
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
.where(
and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds))
);
const niceId = await getUniqueResourcePolicyName(orgId);
for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
} else if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
}
const policy = await db.transaction(async (trx) => {
const [newPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId,
orgId,
name,
sso,
idpId: skipToIdpId,
applyRules,
emailWhitelistEnabled
})
.returning();
const rolesToAdd = [
{
roleId: adminRole[0].roleId,
resourcePolicyId: newPolicy.resourcePolicyId
}
] satisfies InferInsertModel<typeof rolePolicies>[];
rolesToAdd.push(
...existingRoles.map((role) => ({
roleId: role.roleId,
resourcePolicyId: newPolicy.resourcePolicyId
}))
);
await trx.insert(rolePolicies).values(rolesToAdd);
const usersToAdd: InferInsertModel<typeof userPolicies>[] = [];
if (
req.user &&
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
) {
// make sure the user can access the policy
usersToAdd.push({
userId: req.user?.userId!,
resourcePolicyId: newPolicy.resourcePolicyId
});
}
usersToAdd.push(
...existingUsers.map(({ user }) => ({
userId: user.userId,
resourcePolicyId: newPolicy.resourcePolicyId
}))
);
if (usersToAdd.length > 0) {
await trx.insert(userPolicies).values(usersToAdd);
}
if (password) {
const passwordHash = await hashPassword(password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId: newPolicy.resourcePolicyId,
passwordHash
});
}
if (pincode) {
const pincodeHash = await hashPassword(pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId: newPolicy.resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
if (headerAuth) {
const headerAuthHash = await hashPassword(
Buffer.from(
`${headerAuth.user}:${headerAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId: newPolicy.resourcePolicyId,
headerAuthHash,
extendedCompatibility: headerAuth.extendedCompatibility
});
}
if (emailWhitelistEnabled && emails.length > 0) {
await trx.insert(resourcePolicyWhiteList).values(
emails.map((email) => ({
email,
resourcePolicyId: newPolicy.resourcePolicyId
}))
);
}
if (rules.length > 0) {
await trx.insert(resourcePolicyRules).values(
rules.map((rule) => ({
resourcePolicyId: newPolicy.resourcePolicyId,
...rule
}))
);
}
return newPolicy;
});
if (!policy) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create policy"
)
);
}
return response<ResourcePolicy>(res, {
data: policy,
success: true,
error: false,
message: "resource policy created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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 { db, resourcePolicies, resources } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { eq } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import z from "zod";
import { fromError } from "zod-validation-error";
// Define Zod schema for request parameters validation
const deleteResourcePolicySchema = z.strictObject({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "delete",
path: "/resource-policy/{resourcePolicyId}",
description: "Delete a resource policy.",
tags: [OpenAPITags.Policy],
request: {
params: deleteResourcePolicySchema
},
responses: {}
});
export async function deleteResourcePolicy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = deleteResourcePolicySchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const [existingResource] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
if (!existingResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource Policy with ID ${resourcePolicyId} not found`
)
);
}
const totalAffectedResources = await db.$count(
db
.select()
.from(resources)
.where(eq(resources.resourcePolicyId, resourcePolicyId))
);
if (totalAffectedResources > 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Cannot delete Policy '${existingResource.name}' as it's being used by at least one resource`
)
);
}
// delete policy
await db
.delete(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
return response(res, {
data: null,
success: true,
error: false,
message: "Resource Policy deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from "./createResourcePolicy";
export * from "./listResourcePolicies";
export * from "./deleteResourcePolicy";

View File

@@ -0,0 +1,271 @@
/*
* 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 {
db,
resourcePolicies,
resources,
rolePolicies,
userPolicies
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import type {
ListResourcePoliciesResponse,
ResourcePolicyWithResources
} from "@server/routers/resource/types";
import HttpCode from "@server/types/HttpCode";
import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
const listResourcePoliciesParamsSchema = z.strictObject({
orgId: z.string()
});
const listResourcePoliciesSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
function queryResourcePoliciesBase() {
return db
.select({
resourcePolicyId: resourcePolicies.resourcePolicyId,
name: resourcePolicies.name,
niceId: resourcePolicies.niceId,
orgId: resourcePolicies.orgId
})
.from(resourcePolicies);
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource-policies",
description: "List resource policies for an organization.",
tags: [OpenAPITags.Org, OpenAPITags.Policy],
request: {
params: z.object({
orgId: z.string()
}),
query: listResourcePoliciesSchema
},
responses: {}
});
export async function listResourcePolicies(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listResourcePoliciesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedQuery.error)
)
);
}
const { page, pageSize, query } = parsedQuery.data;
const parsedParams = listResourcePoliciesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsedParams.error)
)
);
}
const orgId =
parsedParams.data.orgId ||
req.userOrg?.orgId ||
req.apiKeyOrg?.orgId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>;
if (req.user) {
accessibleResourcePolicies = await db
.select({
resourcePolicyId: sql<number>`COALESCE(${userPolicies.resourcePolicyId}, ${rolePolicies.resourcePolicyId})`
})
.from(userPolicies)
.fullJoin(
rolePolicies,
eq(
userPolicies.resourcePolicyId,
rolePolicies.resourcePolicyId
)
)
.where(
or(
eq(userPolicies.userId, req.user!.userId),
inArray(rolePolicies.roleId, req.userOrgRoleIds || [])
)
);
} else {
accessibleResourcePolicies = await db
.select({
resourcePolicyId: resourcePolicies.resourcePolicyId
})
.from(resourcePolicies)
.where(eq(resourcePolicies.orgId, orgId));
}
const accessibleResourceIds = accessibleResourcePolicies.map(
(resource) => resource.resourcePolicyId
);
const conditions = [
and(
inArray(
resourcePolicies.resourcePolicyId,
accessibleResourceIds
),
eq(resourcePolicies.orgId, orgId),
eq(resourcePolicies.scope, "global")
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${resourcePolicies.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resourcePolicies.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
const baseQuery = queryResourcePoliciesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(baseQuery.as("filtered_policies"));
const [rows, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(resourcePolicies.resourcePolicyId)),
countQuery
]);
const attachedResources =
rows.length === 0
? []
: await db
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
resourcePolicyId: resources.resourcePolicyId
})
.from(resources)
.where(
inArray(
resources.resourcePolicyId,
rows.map((row) => row.resourcePolicyId)
)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourcePolicyWithResources>();
for (const row of rows) {
let entry = map.get(row.resourcePolicyId);
if (!entry) {
entry = {
...row,
resources: []
};
map.set(row.resourcePolicyId, entry);
}
entry.resources = attachedResources.filter(
(r) => r.resourcePolicyId === entry?.resourcePolicyId
);
}
const policiesList = Array.from(map.values());
return response<ListResourcePoliciesResponse>(res, {
data: {
policies: policiesList,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Resources retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -671,7 +671,8 @@ export async function verifyResourceSession(
resourceData.org resourceData.org
); );
localCache.set(userAccessCacheKey, allowedUserData, 5); // this is query intensive so let it cache a little longer
localCache.set(userAccessCacheKey, allowedUserData, 12);
} }
if ( if (
@@ -1003,11 +1004,7 @@ async function checkRules(
isIpInCidr(clientIp, rule.value) isIpInCidr(clientIp, rule.value)
) { ) {
return rule.action as any; return rule.action as any;
} else if ( } else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
clientIp &&
rule.match == "IP" &&
clientIp == rule.value
) {
return rule.action as any; return rule.action as any;
} else if ( } else if (
path && path &&
@@ -1015,10 +1012,7 @@ async function checkRules(
isPathAllowed(rule.value, path) isPathAllowed(rule.value, path)
) { ) {
return rule.action as any; return rule.action as any;
} else if ( } else if (clientIp && rule.match == "COUNTRY") {
clientIp &&
rule.match == "COUNTRY"
) {
// COUNTRY=ALL should not affect local/private/CGNAT addresses. // COUNTRY=ALL should not affect local/private/CGNAT addresses.
if ( if (
rule.value.toUpperCase() === "ALL" && rule.value.toUpperCase() === "ALL" &&
@@ -1030,10 +1024,7 @@ async function checkRules(
if (await isIpInGeoIP(ipCC, rule.value)) { if (await isIpInGeoIP(ipCC, rule.value)) {
return rule.action as any; return rule.action as any;
} }
} else if ( } else if (clientIp && rule.match == "ASN") {
clientIp &&
rule.match == "ASN"
) {
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses. // ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
if ( if (
(rule.value.toUpperCase() === "ALL" || (rule.value.toUpperCase() === "ALL" ||
@@ -1272,11 +1263,15 @@ export async function isIpInRegion(
if (region.id === checkRegionCode) { if (region.id === checkRegionCode) {
for (const subregion of region.includes) { for (const subregion of region.includes) {
if (subregion.countries.includes(upperCode)) { if (subregion.countries.includes(upperCode)) {
logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`); logger.debug(
`Country ${upperCode} is in region ${region.id} (${region.name})`
);
return true; return true;
} }
} }
logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`); logger.debug(
`Country ${upperCode} is not in region ${region.id} (${region.name})`
);
return false; return false;
} }
@@ -1284,10 +1279,14 @@ export async function isIpInRegion(
for (const subregion of region.includes) { for (const subregion of region.includes) {
if (subregion.id === checkRegionCode) { if (subregion.id === checkRegionCode) {
if (subregion.countries.includes(upperCode)) { if (subregion.countries.includes(upperCode)) {
logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`); logger.debug(
`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`
);
return true; return true;
} }
logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`); logger.debug(
`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`
);
return false; return false;
} }
} }

View File

@@ -3,6 +3,7 @@ import config from "@server/lib/config";
import * as site from "./site"; import * as site from "./site";
import * as org from "./org"; import * as org from "./org";
import * as resource from "./resource"; import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain"; import * as domain from "./domain";
import * as target from "./target"; import * as target from "./target";
import * as user from "./user"; import * as user from "./user";
@@ -42,7 +43,8 @@ import {
verifyUserIsOrgOwner, verifyUserIsOrgOwner,
verifySiteResourceAccess, verifySiteResourceAccess,
verifyOlmAccess, verifyOlmAccess,
verifyLimits verifyLimits,
verifyResourcePolicyAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -103,7 +105,6 @@ authenticated.put(
site.createSite site.createSite
); );
authenticated.get( authenticated.get(
"/org/:orgId/sites", "/org/:orgId/sites",
verifyOrgAccess, verifyOrgAccess,
@@ -540,6 +541,7 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getResource), verifyUserHasAction(ActionsEnum.getResource),
resource.getResource resource.getResource
); );
authenticated.post( authenticated.post(
"/resource/:resourceId", "/resource/:resourceId",
verifyResourceAccess, verifyResourceAccess,
@@ -646,6 +648,29 @@ authenticated.post(
logActionAudit(ActionsEnum.updateRole), logActionAudit(ActionsEnum.updateRole),
role.updateRole role.updateRole
); );
authenticated.get(
"/org/:orgId/resource-policy/:niceId",
verifyOrgAccess,
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.get(
"/resource/:resourceId/policies",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.getResourcePolicy),
resource.getResourcePolicies
);
authenticated.put(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
policy.updateResourcePolicy
);
// authenticated.get( // authenticated.get(
// "/role/:roleId", // "/role/:roleId",
// verifyRoleAccess, // verifyRoleAccess,
@@ -697,6 +722,59 @@ authenticated.post(
resource.setResourceUsers resource.setResourceUsers
); );
authenticated.put(
"/resource-policy/:resourcePolicyId/access-control",
verifyResourcePolicyAccess,
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
logActionAudit(ActionsEnum.setResourcePolicyUsers),
policy.setResourcePolicyAccessControl
);
authenticated.put(
"/resource-policy/:resourcePolicyId/password",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyPassword),
logActionAudit(ActionsEnum.setResourcePolicyPassword),
policy.setResourcePolicyPassword
);
authenticated.put(
"/resource-policy/:resourcePolicyId/pincode",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyPincode),
logActionAudit(ActionsEnum.setResourcePolicyPincode),
policy.setResourcePolicyPincode
);
authenticated.put(
"/resource-policy/:resourcePolicyId/header-auth",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
policy.setResourcePolicyHeaderAuth
);
authenticated.put(
"/resource-policy/:resourcePolicyId/whitelist",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist),
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
policy.setResourcePolicyWhitelist
);
authenticated.put(
"/resource-policy/:resourcePolicyId/rules",
verifyResourcePolicyAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyRules),
logActionAudit(ActionsEnum.setResourcePolicyRules),
policy.setResourcePolicyRules
);
authenticated.post( authenticated.post(
`/resource/:resourceId/password`, `/resource/:resourceId/password`,
verifyResourceAccess, verifyResourceAccess,

View File

@@ -2,6 +2,7 @@ import * as site from "./site";
import * as org from "./org"; import * as org from "./org";
import * as blueprints from "./blueprints"; import * as blueprints from "./blueprints";
import * as resource from "./resource"; import * as resource from "./resource";
import * as policy from "./policy";
import * as domain from "./domain"; import * as domain from "./domain";
import * as target from "./target"; import * as target from "./target";
import * as user from "./user"; import * as user from "./user";
@@ -29,7 +30,9 @@ import {
verifyApiKeySiteResourceAccess, verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients, verifyApiKeySetResourceClients,
verifyLimits, verifyLimits,
verifyApiKeyDomainAccess verifyApiKeyDomainAccess,
verifyApiKeyResourcePolicyAccess,
verifyUserHasAction
} from "@server/middlewares"; } from "@server/middlewares";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { Router } from "express"; import { Router } from "express";
@@ -459,6 +462,20 @@ authenticated.get(
resource.getResource resource.getResource
); );
authenticated.get(
"/resource-policy/:resourcePolicyId",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
policy.getResourcePolicy
);
authenticated.get(
"/resource/:resourceId/policies",
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
resource.getResourcePolicies
);
authenticated.post( authenticated.post(
"/resource/:resourceId", "/resource/:resourceId",
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,
@@ -468,6 +485,13 @@ authenticated.post(
resource.updateResource resource.updateResource
); );
authenticated.put(
"/resource-policy/:resourcePolicyId",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
policy.updateResourcePolicy
);
authenticated.delete( authenticated.delete(
"/resource/:resourceId", "/resource/:resourceId",
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,
@@ -619,6 +643,63 @@ authenticated.post(
resource.setResourceUsers resource.setResourceUsers
); );
authenticated.put(
"/resource-policy/:resourcePolicyId/access-control",
verifyApiKeyResourcePolicyAccess,
verifyApiKeyRoleAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
logActionAudit(ActionsEnum.setResourcePolicyUsers),
logActionAudit(ActionsEnum.setResourcePolicyRoles),
policy.setResourcePolicyAccessControl
);
authenticated.put(
"/resource-policy/:resourcePolicyId/password",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword),
logActionAudit(ActionsEnum.setResourcePolicyPassword),
policy.setResourcePolicyPassword
);
authenticated.put(
"/resource-policy/:resourcePolicyId/pincode",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode),
logActionAudit(ActionsEnum.setResourcePolicyPincode),
policy.setResourcePolicyPincode
);
authenticated.put(
"/resource-policy/:resourcePolicyId/header-auth",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
policy.setResourcePolicyHeaderAuth
);
authenticated.put(
"/resource-policy/:resourcePolicyId/whitelist",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist),
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
policy.setResourcePolicyWhitelist
);
authenticated.put(
"/resource-policy/:resourcePolicyId/rules",
verifyApiKeyResourcePolicyAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules),
logActionAudit(ActionsEnum.setResourcePolicyRules),
policy.setResourcePolicyRules
);
authenticated.post( authenticated.post(
"/resource/:resourceId/roles/add", "/resource/:resourceId/roles/add",
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,

View File

@@ -0,0 +1,231 @@
import {
db,
idp,
resourcePolicyRules,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyWhiteList,
rolePolicies,
roles,
userPolicies,
users
} from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, not, or, type SQL } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import z from "zod";
import { fromError } from "zod-validation-error";
const getResourcePolicySchema = z
.strictObject({
niceId: z.string(),
orgId: z.string()
})
.or(
z.strictObject({
resourcePolicyId: z.coerce
.number<string>()
.int()
.positive()
.openapi({
type: "integer",
description: "Resource policy ID"
})
})
);
export async function queryResourcePolicy(
params: z.infer<typeof getResourcePolicySchema>
) {
const conditions: SQL<unknown>[] = [];
if ("resourcePolicyId" in params) {
conditions.push(
eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId)
);
} else {
conditions.push(
eq(resourcePolicies.niceId, params.niceId),
eq(resourcePolicies.orgId, params.orgId)
);
}
const [res] = await db
.select({
resourcePolicyId: resourcePolicies.resourcePolicyId,
sso: resourcePolicies.sso,
applyRules: resourcePolicies.applyRules,
emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled,
idpId: resourcePolicies.idpId,
niceId: resourcePolicies.niceId,
name: resourcePolicies.name,
passwordId: resourcePolicyPassword.passwordId,
pincodeId: resourcePolicyPincode.pincodeId,
headerAuth: {
id: resourcePolicyHeaderAuth.headerAuthId,
extendedCompability:
resourcePolicyHeaderAuth.extendedCompatibility
}
})
.from(resourcePolicies)
.leftJoin(
resourcePolicyPassword,
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyPincode,
eq(
resourcePolicyPincode.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyHeaderAuth,
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.where(and(...conditions))
.limit(1);
if (!res) return null;
const policyUsers = await db
.select({
userId: userPolicies.userId,
email: users.email,
name: users.name,
username: users.username,
type: users.type,
idpName: idp.name
})
.from(userPolicies)
.innerJoin(users, eq(userPolicies.userId, users.userId))
.leftJoin(idp, eq(idp.idpId, users.idpId))
.where(eq(userPolicies.resourcePolicyId, res.resourcePolicyId));
const policyRoles = await db
.select({
roleId: rolePolicies.roleId,
name: roles.name
})
.from(rolePolicies)
.innerJoin(
roles,
and(
eq(rolePolicies.roleId, roles.roleId),
or(isNull(roles.isAdmin), not(roles.isAdmin))
)
)
.where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId));
const policyEmailWhiteList = await db
.select({
whiteListId: resourcePolicyWhiteList.whitelistId,
email: resourcePolicyWhiteList.email
})
.from(resourcePolicyWhiteList)
.where(
eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId)
);
const policyRules = await db
.select({
ruleId: resourcePolicyRules.ruleId,
enabled: resourcePolicyRules.enabled,
priority: resourcePolicyRules.priority,
action: resourcePolicyRules.action,
match: resourcePolicyRules.match,
value: resourcePolicyRules.value
})
.from(resourcePolicyRules)
.where(eq(resourcePolicyRules.resourcePolicyId, res.resourcePolicyId));
return {
...res,
roles: policyRoles,
users: policyUsers,
emailWhiteList: policyEmailWhiteList,
rules: policyRules
};
}
export type GetResourcePolicyResponse = NonNullable<
Awaited<ReturnType<typeof queryResourcePolicy>>
>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource-policy/{niceId}",
description:
"Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
tags: [OpenAPITags.Org, OpenAPITags.Policy],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/resource-policy/{resourcePolicyId}",
description: "Get a resource policy by its resourcePolicyId.",
tags: [OpenAPITags.Policy],
request: {
params: z.object({
resourcePolicyId: z.number()
})
},
responses: {}
});
export async function getResourcePolicy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getResourcePolicySchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const policy = await queryResourcePolicy(parsedParams.data);
if (!policy) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
);
}
return response<GetResourcePolicyResponse>(res, {
data: policy,
success: true,
error: false,
message: "Resource Policy retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,8 @@
export * from "./getResourcePolicy";
export * from "./updateResourcePolicy";
export * from "./setResourcePolicyAccessControl";
export * from "./setResourcePolicyPassword";
export * from "./setResourcePolicyPincode";
export * from "./setResourcePolicyHeaderAuth";
export * from "./setResourcePolicyWhitelist";
export * from "./setResourcePolicyRules";

View File

@@ -0,0 +1,237 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
idp,
idpOrg,
resourcePolicies,
rolePolicies,
roles,
userOrgs,
users
} from "@server/db";
import { userPolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq, inArray, ne } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const setResourcePolicyAcccessControlBodySchema = z.strictObject({
sso: z.boolean(),
userIds: z.array(z.string()),
roleIds: z.array(z.int().positive()).openapi({
type: "array"
}),
skipToIdpId: z.int().positive().optional().nullable().openapi({
type: "integer",
description: "Page number to retrieve"
})
});
const setResourcePolicyAccessControlParamsSchema = z.strictObject({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "post",
path: "/resource-policy/{resourceId}/access-control",
description:
"Set access control users for a resource policy, including SSO, users, roles, Identity provider.",
tags: [OpenAPITags.Policy, OpenAPITags.User],
request: {
params: setResourcePolicyAccessControlParamsSchema,
body: {
content: {
"application/json": {
schema: setResourcePolicyAcccessControlBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePolicyAccessControl(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setResourcePolicyAcccessControlBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userIds, roleIds, sso, skipToIdpId: idpId } = parsedBody.data;
const parsedParams =
setResourcePolicyAccessControlParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
if (!policy) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Resource policy not found"
)
);
}
// Check if Identity provider in `skipToIdpId` exists
if (idpId) {
const [provider] = await db
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(
and(eq(idp.idpId, idpId), eq(idpOrg.orgId, policy.orgId))
)
.limit(1);
if (!provider) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Identity provider not found in this organization"
)
);
}
}
// Check if any of the roleIds are admin roles
const rolesToCheck = await db
.select()
.from(roles)
.where(
and(
inArray(roles.roleId, roleIds),
eq(roles.orgId, policy.orgId)
)
);
const hasAdminRole = rolesToCheck.some((role) => role.isAdmin);
if (hasAdminRole) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Admin role cannot be assigned to resources"
)
);
}
// Get all admin role IDs for this org to exclude from deletion
const adminRoles = await db
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId)));
const adminRoleIds = adminRoles.map((role) => role.roleId);
const existingUsers = await db
.select()
.from(users)
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
.where(
and(
eq(userOrgs.orgId, policy.orgId),
inArray(users.userId, userIds)
)
);
const existingRoles = await db
.select()
.from(roles)
.where(
and(
eq(roles.orgId, policy.orgId),
inArray(roles.roleId, roleIds)
)
);
await db.transaction(async (trx) => {
// Update SSO status
await trx
.update(resourcePolicies)
.set({
sso,
idpId
})
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
// Update roles
if (adminRoleIds.length > 0) {
await trx.delete(rolePolicies).where(
and(
eq(rolePolicies.resourcePolicyId, resourcePolicyId),
ne(rolePolicies.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
await trx
.delete(rolePolicies)
.where(eq(rolePolicies.resourcePolicyId, resourcePolicyId));
}
const rolesToAdd = existingRoles.map(({ roleId }) => ({
roleId,
resourcePolicyId
}));
if (rolesToAdd.length > 0) {
await trx.insert(rolePolicies).values(rolesToAdd);
}
// Update users
await trx
.delete(userPolicies)
.where(eq(userPolicies.resourcePolicyId, resourcePolicyId));
const usersToAdd = existingUsers.map(({ user }) => ({
userId: user.userId,
resourcePolicyId: resourcePolicyId
}));
if (usersToAdd.length > 0) {
await trx.insert(userPolicies).values(usersToAdd);
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "Resource policy succesfully updated",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,117 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourcePolicyHeaderAuth } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { OpenAPITags, registry } from "@server/openApi";
const setResourcePolicyHeaderAuthParamsSchema = z.object({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
const setResourcePolicyHeaderAuthBodySchema = z.strictObject({
headerAuth: z
.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100),
extendedCompatibility: z.boolean()
})
.nullable()
});
registry.registerPath({
method: "put",
path: "/resource-policy/{resourcePolicyId}/header-auth",
description:
"Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.",
tags: [OpenAPITags.Policy],
request: {
params: setResourcePolicyHeaderAuthParamsSchema,
body: {
content: {
"application/json": {
schema: setResourcePolicyHeaderAuthBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePolicyHeaderAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setResourcePolicyHeaderAuthParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setResourcePolicyHeaderAuthBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const { headerAuth } = parsedBody.data;
await db.transaction(async (trx) => {
await trx
.delete(resourcePolicyHeaderAuth)
.where(
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicyId
)
);
if (headerAuth !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(
`${headerAuth.user}:${headerAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,
extendedCompatibility: headerAuth.extendedCompatibility
});
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "Header Authentication set successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,106 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourcePolicyPassword } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { OpenAPITags, registry } from "@server/openApi";
const setResourcePolicyPasswordParamsSchema = z.object({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
const setResourcePolicyPasswordBodySchema = z.strictObject({
password: z.string().min(4).max(100).nullable()
});
registry.registerPath({
method: "put",
path: "/resource-policy/{resourcePolicyId}/password",
description:
"Set the password for a resource policy. Setting the password to null will remove it.",
tags: [OpenAPITags.Policy],
request: {
params: setResourcePolicyPasswordParamsSchema,
body: {
content: {
"application/json": {
schema: setResourcePolicyPasswordBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePolicyPassword(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setResourcePolicyPasswordParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setResourcePolicyPasswordBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const { password } = parsedBody.data;
await db.transaction(async (trx) => {
await trx
.delete(resourcePolicyPassword)
.where(
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicyId
)
);
if (password) {
const passwordHash = await hashPassword(password);
await trx
.insert(resourcePolicyPassword)
.values({ resourcePolicyId, passwordHash });
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "Resource policy password set successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,106 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourcePolicyPincode } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { OpenAPITags, registry } from "@server/openApi";
const setResourcePolicyPincodeParamsSchema = z.object({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
const setResourcePolicyPincodeBodySchema = z.strictObject({
pincode: z
.string()
.regex(/^\d{6}$/)
.or(z.null())
});
registry.registerPath({
method: "put",
path: "/resource-policy/{resourcePolicyId}/pincode",
description:
"Set the PIN code for a resource policy. Setting the PIN code to null will remove it.",
tags: [OpenAPITags.Policy],
request: {
params: setResourcePolicyPincodeParamsSchema,
body: {
content: {
"application/json": {
schema: setResourcePolicyPincodeBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePolicyPincode(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setResourcePolicyPincodeParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setResourcePolicyPincodeBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const { pincode } = parsedBody.data;
await db.transaction(async (trx) => {
await trx
.delete(resourcePolicyPincode)
.where(
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
);
if (pincode) {
const pincodeHash = await hashPassword(pincode);
await trx
.insert(resourcePolicyPincode)
.values({ resourcePolicyId, pincodeHash, digitLength: 6 });
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "Resource policy PIN code set successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,167 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourcePolicyRules, resourcePolicies } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
const ruleSchema = z.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
type: "string",
enum: ["ACCEPT", "DROP", "PASS"],
description: "rule action"
}),
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
type: "string",
enum: ["CIDR", "IP", "PATH"],
description: "rule match"
}),
value: z.string().min(1),
priority: z.int().openapi({
type: "integer",
description: "Rule priority"
}),
enabled: z.boolean().optional()
});
const setResourcePolicyRulesBodySchema = z.strictObject({
applyRules: z.boolean(),
rules: z.array(ruleSchema)
});
const setResourcePolicyRulesParamsSchema = z.strictObject({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "put",
path: "/resource-policy/{resourcePolicyId}/rules",
description:
"Set all rules for a resource policy at once. This will replace all existing rules.",
tags: [OpenAPITags.Policy],
request: {
params: setResourcePolicyRulesParamsSchema,
body: {
content: {
"application/json": {
schema: setResourcePolicyRulesBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePolicyRules(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setResourcePolicyRulesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setResourcePolicyRulesBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const { applyRules, rules } = parsedBody.data;
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
if (!policy) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
);
}
for (const rule of rules) {
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
} else if (
rule.match === "PATH" &&
!isValidUrlGlobPattern(rule.value)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
}
await db.transaction(async (trx) => {
await trx
.update(resourcePolicies)
.set({ applyRules })
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
await trx
.delete(resourcePolicyRules)
.where(
eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId)
);
if (rules.length > 0) {
await trx.insert(resourcePolicyRules).values(
rules.map((rule) => ({
resourcePolicyId,
...rule
}))
);
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "Resource policy rules set successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,132 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourcePolicies, resourcePolicyWhiteList } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const setResourcePolicyWhitelistBodySchema = z.strictObject({
emailWhitelistEnabled: z.boolean(),
emails: z
.array(
z.email().or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
error: "Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
});
const setResourcePolicyWhitelistParamsSchema = z.strictObject({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "put",
path: "/resource-policy/{resourcePolicyId}/whitelist",
description:
"Set email whitelist for a resource policy. This will replace all existing emails.",
tags: [OpenAPITags.Policy],
request: {
params: setResourcePolicyWhitelistParamsSchema,
body: {
content: {
"application/json": {
schema: setResourcePolicyWhitelistBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePolicyWhitelist(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setResourcePolicyWhitelistBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = setResourcePolicyWhitelistParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourcePolicyId } = parsedParams.data;
const { emailWhitelistEnabled, emails } = parsedBody.data;
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
if (!policy) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
);
}
await db.transaction(async (trx) => {
await trx
.update(resourcePolicies)
.set({ emailWhitelistEnabled })
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
// delete all whitelist emails
await trx
.delete(resourcePolicyWhiteList)
.where(
eq(
resourcePolicyWhiteList.resourcePolicyId,
resourcePolicyId
)
);
if (emailWhitelistEnabled && emails.length > 0) {
await trx.insert(resourcePolicyWhiteList).values(
emails.map((email) => ({
email,
resourcePolicyId
}))
);
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "Whitelist set for resource policy successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,157 @@
import { Request, Response, NextFunction } from "express";
import z from "zod";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import { db, orgs, resourcePolicies, type ResourcePolicy } from "@server/db";
import { and, eq } from "drizzle-orm";
import logger from "@server/logger";
import response from "@server/lib/response";
const updateResourcePolicyParamsSchema = z.strictObject({
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
});
const updateResourcePolicyBodySchema = z.strictObject({
name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional()
});
registry.registerPath({
method: "put",
path: "/resource-policy/{resourcePolicyId}",
description: "Update a resource policy.",
tags: [OpenAPITags.Org, OpenAPITags.Policy],
request: {
params: updateResourcePolicyParamsSchema,
body: {
content: {
"application/json": {
schema: updateResourcePolicyBodySchema
}
}
}
},
responses: {}
});
export async function updateResourcePolicy(
req: Request,
res: Response,
next: NextFunction
) {
try {
const parsedParams = updateResourcePolicyParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
if (req.user && req.userOrgRoleIds?.length === 0) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
}
const { resourcePolicyId } = parsedParams.data;
const [result] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.leftJoin(orgs, eq(resourcePolicies.orgId, orgs.orgId));
const policy = result?.resourcePolicies;
const org = result?.orgs;
if (!policy || !org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource Policy with ID ${resourcePolicyId} not found`
)
);
}
const parsedBody = updateResourcePolicyBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const updateData = parsedBody.data;
if (updateData.niceId) {
const [existingPolicy] = await db
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, updateData.niceId),
eq(resourcePolicies.orgId, policy.orgId)
)
);
if (
existingPolicy &&
existingPolicy.resourcePolicyId !== policy.resourcePolicyId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A resource policy with niceId "${updateData.niceId}" already exists`
)
);
}
}
const updatedPolicy = await db.transaction(async (trx) => {
const [updated] = await trx
.update(resourcePolicies)
.set({
...updateData
})
.where(
eq(
resourcePolicies.resourcePolicyId,
policy.resourcePolicyId
)
)
.returning();
return updated;
});
if (!updatedPolicy) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update policy"
)
);
}
return response<ResourcePolicy>(res, {
data: updatedPolicy,
success: true,
error: false,
message: "Resource policy updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,15 +1,19 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, domainNamespaces, loginPage } from "@server/db"; import { build } from "@server/build";
import { import {
domains, db,
orgDomains, loginPage,
orgs, orgs,
Resource, Resource,
resources, resources,
resourcePolicies,
roleResources, roleResources,
rolePolicies,
roles, roles,
userResources userPolicies,
userResources,
domainNamespaces
} from "@server/db"; } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -20,13 +24,18 @@ import logger from "@server/logger";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names"; import {
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
getUniqueResourceName,
getUniqueResourcePolicyName
} from "@server/db/names";
const createResourceParamsSchema = z.strictObject({ const createResourceParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -311,8 +320,46 @@ async function createHttpResource(
let resource: Resource | undefined; let resource: Resource | undefined;
const niceId = await getUniqueResourceName(orgId); const niceId = await getUniqueResourceName(orgId);
const policyNiceId = await getUniqueResourcePolicyName(orgId);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const adminRole = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
const [defaultPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: `default policy for ${niceId}`,
sso: true,
scope: "resource"
})
.returning();
// make this policy visible by the admin role
await trx.insert(rolePolicies).values({
roleId: adminRole[0].roleId,
resourcePolicyId: defaultPolicy.resourcePolicyId
});
// make this policy visible by the current user
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
await trx.insert(userPolicies).values({
userId: req.user?.userId!,
resourcePolicyId: defaultPolicy.resourcePolicyId
});
}
const newResource = await trx const newResource = await trx
.insert(resources) .insert(resources)
.values({ .values({
@@ -328,22 +375,11 @@ async function createHttpResource(
stickySession: stickySession, stickySession: stickySession,
postAuthPath: postAuthPath, postAuthPath: postAuthPath,
wildcard, wildcard,
health: "unknown" health: "unknown",
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
}) })
.returning(); .returning();
const adminRole = await db
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (adminRole.length === 0) {
return next(
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
);
}
await trx.insert(roleResources).values({ await trx.insert(roleResources).values({
roleId: adminRole[0].roleId, roleId: adminRole[0].roleId,
resourceId: newResource[0].resourceId resourceId: newResource[0].resourceId
@@ -369,7 +405,7 @@ async function createHttpResource(
); );
} }
if (build != "oss") { if (build !== "oss") {
await createCertificate(domainId, fullDomain, db); await createCertificate(domainId, fullDomain, db);
} }
@@ -410,22 +446,10 @@ async function createRawResource(
let resource: Resource | undefined; let resource: Resource | undefined;
const niceId = await getUniqueResourceName(orgId); const niceId = await getUniqueResourceName(orgId);
const policyNiceId = await getUniqueResourcePolicyName(orgId);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const newResource = await trx const adminRole = await trx
.insert(resources)
.values({
niceId,
orgId,
name,
http,
protocol,
proxyPort
// enableProxy
})
.returning();
const adminRole = await db
.select() .select()
.from(roles) .from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
@@ -437,6 +461,44 @@ async function createRawResource(
); );
} }
const [defaultPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: `default policy for ${niceId}`,
sso: true,
scope: "resource"
})
.returning();
// make this policy visible by the admin role
await trx.insert(rolePolicies).values({
roleId: adminRole[0].roleId,
resourcePolicyId: defaultPolicy.resourcePolicyId
});
// make this policy visible by the current user
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
await trx.insert(userPolicies).values({
userId: req.user?.userId!,
resourcePolicyId: defaultPolicy.resourcePolicyId
});
}
const newResource = await trx
.insert(resources)
.values({
niceId,
orgId,
name,
http,
protocol,
proxyPort,
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
})
.returning();
await trx.insert(roleResources).values({ await trx.insert(roleResources).values({
roleId: adminRole[0].roleId, roleId: adminRole[0].roleId,
resourceId: newResource[0].resourceId resourceId: newResource[0].resourceId

View File

@@ -1,17 +1,22 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, targetHealthCheck } from "@server/db";
import { newts, resources, sites, targets } from "@server/db";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "../target/helpers";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets";
// Define Zod schema for request parameters validation // Define Zod schema for request parameters validation
const deleteResourceSchema = z.strictObject({ const deleteResourceSchema = z.strictObject({
@@ -113,6 +118,18 @@ export async function deleteResource(
} }
} }
// Also delete default resource policy
if (deletedResource.defaultResourcePolicyId) {
await db
.delete(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
deletedResource.defaultResourcePolicyId
)
);
}
return response(res, { return response(res, {
data: null, data: null,
success: true, success: true,

View File

@@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import {
db, db,
resourceHeaderAuth, resourcePolicies,
resourceHeaderAuthExtendedCompatibility, resourcePolicyHeaderAuth,
resourcePassword, resourcePolicyPassword,
resourcePincode, resourcePolicyPincode,
resources resources
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -60,64 +60,53 @@ export async function getResourceAuthInfo(
const isGuidInteger = /^\d+$/.test(resourceGuid); const isGuidInteger = /^\d+$/.test(resourceGuid);
const buildQuery = (whereClause: ReturnType<typeof eq>) =>
db
.select()
.from(resources)
.leftJoin(
resourcePolicies,
or(
eq(
resourcePolicies.resourcePolicyId,
resources.resourcePolicyId
),
eq(
resourcePolicies.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
)
.leftJoin(
resourcePolicyPincode,
eq(
resourcePolicyPincode.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyPassword,
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyHeaderAuth,
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.where(whereClause)
.limit(1);
const [result] = const [result] =
isGuidInteger && build === "saas" isGuidInteger && build === "saas"
? await db ? await buildQuery(
.select() eq(resources.resourceId, Number(resourceGuid))
.from(resources) )
.leftJoin( : await buildQuery(eq(resources.resourceGuid, resourceGuid));
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(
resourceHeaderAuth.resourceId,
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceId, Number(resourceGuid)))
.limit(1)
: await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(
resourceHeaderAuth.resourceId,
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceGuid, resourceGuid))
.limit(1);
const resource = result?.resources; const resource = result?.resources;
if (!resource) { if (!resource) {
@@ -126,11 +115,10 @@ export async function getResourceAuthInfo(
); );
} }
const pincode = result?.resourcePincode; const policy = result?.resourcePolicies;
const password = result?.resourcePassword; const pincode = result?.resourcePolicyPincode;
const headerAuth = result?.resourceHeaderAuth; const password = result?.resourcePolicyPassword;
const headerAuthExtendedCompatibility = const headerAuth = result?.resourcePolicyHeaderAuth;
result?.resourceHeaderAuthExtendedCompatibility;
const url = resource.fullDomain const url = resource.fullDomain
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` ? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
@@ -146,13 +134,13 @@ export async function getResourceAuthInfo(
pincode: pincode !== null, pincode: pincode !== null,
headerAuth: headerAuth !== null, headerAuth: headerAuth !== null,
headerAuthExtendedCompatibility: headerAuthExtendedCompatibility:
headerAuthExtendedCompatibility !== null, headerAuth?.extendedCompatibility ?? false,
sso: resource.sso, sso: policy?.sso ?? false,
blockAccess: resource.blockAccess, blockAccess: resource.blockAccess,
url: url ?? "", url: url ?? "",
wildcard: resource.wildcard ?? false, wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain, fullDomain: resource.fullDomain,
whitelist: resource.emailWhitelistEnabled, whitelist: policy?.emailWhitelistEnabled ?? false,
skipToIdpId: resource.skipToIdpId, skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId, orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null postAuthPath: resource.postAuthPath ?? null

View File

@@ -0,0 +1,109 @@
import { db, resources } from "@server/db";
import {
queryResourcePolicy,
type GetResourcePolicyResponse
} from "@server/routers/policy/getResourcePolicy";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { eq } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import z from "zod";
import { fromError } from "zod-validation-error";
const getResourcePoliciesParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
});
export type GetResourcePoliciesResponse = {
defaultPolicy: GetResourcePolicyResponse;
sharedPolicy: GetResourcePolicyResponse | null;
};
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/policies",
description: "Get the inline and shared policies associated with a resource.",
tags: [OpenAPITags.PublicResource, OpenAPITags.Policy],
request: {
params: getResourcePoliciesParamsSchema
},
responses: {}
});
export async function getResourcePolicies(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getResourcePoliciesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const [resource] = await db
.select({
defaultResourcePolicyId: resources.defaultResourcePolicyId,
resourcePolicyId: resources.resourcePolicyId
})
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
if (!resource.defaultResourcePolicyId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource has no default policy"
)
);
}
const [defaultPolicy, sharedPolicy] = await Promise.all([
queryResourcePolicy({
resourcePolicyId: resource.defaultResourcePolicyId
}),
resource.resourcePolicyId
? queryResourcePolicy({
resourcePolicyId: resource.resourcePolicyId
})
: null
]);
return response<GetResourcePoliciesResponse>(res, {
data: {
defaultPolicy:
// the policy will always be non nullable
defaultPolicy as unknown as GetResourcePolicyResponse,
sharedPolicy
},
success: true,
error: false,
message: "Resource policies retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

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

View File

@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
export * from "./listAllResourceNames"; export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory"; export * from "./getStatusHistory";
export * from "./getResourcePolicies";

View File

@@ -1,9 +1,9 @@
import { import {
db, db,
resourceHeaderAuth, resourcePolicies,
resourceHeaderAuthExtendedCompatibility, resourcePolicyHeaderAuth,
resourcePassword, resourcePolicyPassword,
resourcePincode, resourcePolicyPincode,
resources, resources,
roleResources, roleResources,
sites, sites,
@@ -163,10 +163,10 @@ function queryResourcesBase() {
name: resources.name, name: resources.name,
ssl: resources.ssl, ssl: resources.ssl,
fullDomain: resources.fullDomain, fullDomain: resources.fullDomain,
passwordId: resourcePassword.passwordId, passwordId: resourcePolicyPassword.passwordId,
sso: resources.sso, sso: resourcePolicies.sso,
pincodeId: resourcePincode.pincodeId, pincodeId: resourcePolicyPincode.pincodeId,
whitelist: resources.emailWhitelistEnabled, whitelist: resourcePolicies.emailWhitelistEnabled,
http: resources.http, http: resources.http,
protocol: resources.protocol, protocol: resources.protocol,
proxyPort: resources.proxyPort, proxyPort: resources.proxyPort,
@@ -174,29 +174,45 @@ function queryResourcesBase() {
domainId: resources.domainId, domainId: resources.domainId,
niceId: resources.niceId, niceId: resources.niceId,
wildcard: resources.wildcard, wildcard: resources.wildcard,
headerAuthId: resourceHeaderAuth.headerAuthId, health: resources.health,
headerAuthExtendedCompatibilityId: headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, headerAuthExtendedCompatibility:
health: resources.health resourcePolicyHeaderAuth.extendedCompatibility
}) })
.from(resources) .from(resources)
.leftJoin( .leftJoin(
resourcePassword, resourcePolicies,
eq(resourcePassword.resourceId, resources.resourceId) or(
eq(
resourcePolicies.resourcePolicyId,
resources.resourcePolicyId
),
eq(
resourcePolicies.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
) )
.leftJoin( .leftJoin(
resourcePincode, resourcePolicyPassword,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq( eq(
resourceHeaderAuthExtendedCompatibility.resourceId, resourcePolicyPassword.resourcePolicyId,
resources.resourceId resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyPincode,
eq(
resourcePolicyPincode.resourcePolicyId,
resourcePolicies.resourcePolicyId
)
)
.leftJoin(
resourcePolicyHeaderAuth,
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicies.resourcePolicyId
) )
) )
.leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .leftJoin(targets, eq(targets.resourceId, resources.resourceId))
@@ -206,10 +222,10 @@ function queryResourcesBase() {
) )
.groupBy( .groupBy(
resources.resourceId, resources.resourceId,
resourcePassword.passwordId, resourcePolicies.resourcePolicyId,
resourcePincode.pincodeId, resourcePolicyPassword.passwordId,
resourceHeaderAuth.headerAuthId, resourcePolicyPincode.pincodeId,
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId resourcePolicyHeaderAuth.headerAuthId
); );
} }
@@ -355,21 +371,21 @@ export async function listResources(
case "protected": case "protected":
conditions.push( conditions.push(
or( or(
eq(resources.sso, true), eq(resourcePolicies.sso, true),
eq(resources.emailWhitelistEnabled, true), eq(resourcePolicies.emailWhitelistEnabled, true),
not(isNull(resourceHeaderAuth.headerAuthId)), not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
not(isNull(resourcePincode.pincodeId)), not(isNull(resourcePolicyPincode.pincodeId)),
not(isNull(resourcePassword.passwordId)) not(isNull(resourcePolicyPassword.passwordId))
) )
); );
break; break;
case "not_protected": case "not_protected":
conditions.push( conditions.push(
not(eq(resources.sso, true)), not(eq(resourcePolicies.sso, true)),
not(eq(resources.emailWhitelistEnabled, true)), not(eq(resourcePolicies.emailWhitelistEnabled, true)),
isNull(resourceHeaderAuth.headerAuthId), isNull(resourcePolicyHeaderAuth.headerAuthId),
isNull(resourcePincode.pincodeId), isNull(resourcePolicyPincode.pincodeId),
isNull(resourcePassword.passwordId) isNull(resourcePolicyPassword.passwordId)
); );
break; break;
} }
@@ -446,9 +462,9 @@ export async function listResources(
ssl: row.ssl, ssl: row.ssl,
fullDomain: row.fullDomain, fullDomain: row.fullDomain,
passwordId: row.passwordId, passwordId: row.passwordId,
sso: row.sso, sso: row.sso ?? false,
pincodeId: row.pincodeId, pincodeId: row.pincodeId,
whitelist: row.whitelist, whitelist: row.whitelist ?? false,
http: row.http, http: row.http,
protocol: row.protocol, protocol: row.protocol,
proxyPort: row.proxyPort, proxyPort: row.proxyPort,

View File

@@ -1,3 +1,6 @@
import type { Resource, ResourcePolicy } from "@server/db";
import type { PaginatedResponse } from "@server/types/Pagination";
export type GetMaintenanceInfoResponse = { export type GetMaintenanceInfoResponse = {
resourceId: number; resourceId: number;
name: string; name: string;
@@ -8,3 +11,19 @@ export type GetMaintenanceInfoResponse = {
maintenanceMessage: string | null; maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null; maintenanceEstimatedTime: string | null;
}; };
export type AttachedResource = Pick<
Resource,
"resourceId" | "name" | "fullDomain"
>;
export type ResourcePolicyWithResources = Pick<
ResourcePolicy,
"resourcePolicyId" | "niceId" | "name" | "orgId"
> & {
resources: Array<AttachedResource>;
};
export type ListResourcePoliciesResponse = PaginatedResponse<{
policies: Array<ResourcePolicyWithResources>;
}>;

View File

@@ -1,12 +1,23 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, domainNamespaces, loginPage } from "@server/db"; import {
db,
domainNamespaces,
loginPage,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode,
resourceRules,
resourceWhitelist
} from "@server/db";
import { import {
domains, domains,
Org, Org,
orgDomains, orgDomains,
orgs, orgs,
Resource, Resource,
resourcePolicies,
resources resources
} from "@server/db"; } from "@server/db";
import { eq, and, ne } from "drizzle-orm"; import { eq, and, ne } from "drizzle-orm";
@@ -24,7 +35,10 @@ import {
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import {
validateAndConstructDomain,
checkWildcardDomainConflict
} from "@server/lib/domainUtils";
import { build } from "@server/build"; import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -68,7 +82,8 @@ const updateHttpResourceBodySchema = z
maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional() postAuthPath: z.string().nullable().optional(),
resourcePolicyId: z.number().nullable().optional()
}) })
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update" error: "At least one field must be provided for update"
@@ -165,7 +180,8 @@ const updateRawResourceBodySchema = z
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
proxyProtocol: z.boolean().optional(), proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.int().min(1).optional() proxyProtocolVersion: z.int().min(1).optional(),
resourcePolicyId: z.number().nullable().optional()
}) })
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update" error: "At least one field must be provided for update"
@@ -301,6 +317,42 @@ async function updateHttpResource(
const updateData = parsedBody.data; const updateData = parsedBody.data;
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (updateData.resourcePolicyId != null) {
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Resource policies are not supported on your current plan. Please upgrade to access this feature."
)
);
}
const [existingPolicy] = await db
.select()
.from(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
updateData.resourcePolicyId
)
)
.limit(1);
if (!existingPolicy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${updateData.resourcePolicyId} not found`
)
);
}
}
if (updateData.niceId) { if (updateData.niceId) {
const [existingResource] = await db const [existingResource] = await db
.select() .select()
@@ -326,10 +378,6 @@ async function updateHttpResource(
// Wildcard subdomains are a paid feature // Wildcard subdomains are a paid feature
if (updateData.subdomain && updateData.subdomain.includes("*")) { if (updateData.subdomain && updateData.subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) { if (!isLicensed) {
return next( return next(
createHttpError( createHttpError(
@@ -474,10 +522,6 @@ async function updateHttpResource(
headers = null; headers = null;
} }
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.maintencePage
);
if (!isLicensed) { if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined; updateData.maintenanceModeType = undefined;
@@ -535,38 +579,122 @@ async function updateRawResource(
} }
const updateData = parsedBody.data; const updateData = parsedBody.data;
let updatedResource: Resource | null = null;
if (updateData.niceId) { const [existingResource] = await db
const [existingResource] = await db .select()
.select() .from(resources)
.from(resources)
.where(
and(
eq(resources.niceId, updateData.niceId),
eq(resources.orgId, resource.orgId)
)
);
if (
existingResource &&
existingResource.resourceId !== resource.resourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A resource with niceId "${updateData.niceId}" already exists`
)
);
}
}
const updatedResource = await db
.update(resources)
.set(updateData)
.where(eq(resources.resourceId, resource.resourceId)) .where(eq(resources.resourceId, resource.resourceId))
.returning(); .limit(1);
if (updatedResource.length === 0) { await db.transaction(async (trx) => {
if (updateData.resourcePolicyId != null) {
const [existingPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
updateData.resourcePolicyId
)
)
.limit(1);
if (!existingPolicy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${updateData.resourcePolicyId} not found`
)
);
}
} else {
// we are in an inline policy and we need to clear out the old tables
await Promise.all([
trx
.delete(resourcePassword)
.where(
eq(
resourcePassword.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourcePincode)
.where(
eq(
resourcePincode.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceHeaderAuth)
.where(
eq(
resourceHeaderAuth.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceWhitelist)
.where(
eq(
resourceWhitelist.resourceId,
existingResource.resourceId
)
),
trx
.delete(resourceRules)
.where(
eq(
resourceRules.resourceId,
existingResource.resourceId
)
)
]);
}
if (updateData.niceId) {
const [existingResourceConflict] = await trx
.select()
.from(resources)
.where(
and(
eq(resources.niceId, updateData.niceId),
eq(resources.orgId, resource.orgId)
)
);
if (
existingResourceConflict &&
existingResourceConflict.resourceId !== resource.resourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A resource with niceId "${updateData.niceId}" already exists`
)
);
}
}
[updatedResource] = await trx
.update(resources)
.set(updateData)
.where(eq(resources.resourceId, resource.resourceId))
.returning();
});
if (!updatedResource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
@@ -576,7 +704,7 @@ async function updateRawResource(
} }
return response(res, { return response(res, {
data: updatedResource[0], data: updatedResource,
success: true, success: true,
error: false, error: false,
message: "Non-http Resource updated successfully", message: "Non-http Resource updated successfully",

View File

@@ -135,7 +135,7 @@ const listSitesSchema = z.object({
page: z.coerce page: z.coerce
.number<string>() // for prettier formatting .number<string>() // for prettier formatting
.int() .int()
.min(0) .positive()
.optional() .optional()
.catch(1) .catch(1)
.default(1) .default(1)

View File

@@ -74,14 +74,16 @@ const createSiteResourceSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host") { if (data.mode === "host") {
// Check if it's a valid IP address using zod (v4 or v6) if (data.mode == "host") {
const isValidIP = z // Check if it's a valid IP address using zod (v4 or v6)
// .union([z.ipv4(), z.ipv6()]) const isValidIP = z
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere // .union([z.ipv4(), z.ipv6()])
.safeParse(data.destination).success; .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success;
if (isValidIP) { if (isValidIP) {
return true; return true;
}
} }
// Check if it's a valid domain (hostname pattern, TLD not required) // Check if it's a valid domain (hostname pattern, TLD not required)
@@ -94,12 +96,17 @@ const createSiteResourceSchema = z
data.alias.trim() !== ""; data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
} else if (data.mode === "http") { }
// we have to have a domainId defined return true;
if (!data.domainId) { },
return false; {
} message:
} else if (data.mode === "cidr") { "Destination must be a valid IPV4 address or valid domain AND alias is required"
}
)
.refine(
(data) => {
if (data.mode === "cidr") {
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()]) .union([z.cidrv4(), z.cidrv6()])
@@ -109,8 +116,7 @@ const createSiteResourceSchema = z
return true; return true;
}, },
{ {
message: message: "Destination must be a valid CIDR notation for cidr mode"
"Destination must be a valid IPV4 address or valid domain AND alias is required"
} }
) )
.refine( .refine(

View File

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

View File

@@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) {
.from(userOrgRoles) .from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where( .where(
and( and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
); );
const isAdmin = roleRows.some((r) => r.isAdmin); const isAdmin = roleRows.some((r) => r.isAdmin);
@@ -146,7 +143,7 @@ export async function getOrgUser(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have permission perform this action" "User does not have permission to get organization user details"
) )
); );
} }

View File

@@ -77,7 +77,7 @@ export default async function migration() {
} }
console.log( console.log(
`Updated names for ${existingHealthChecks.length} existing targetHealthCheck row(s)` `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs`
); );
} catch (e) { } catch (e) {
console.error("Error while migrating targetHealthCheck rows:", e); console.error("Error while migrating targetHealthCheck rows:", e);

View File

@@ -500,7 +500,6 @@ export default function GeneralPage() {
onAutoProvisionChange={(checked) => { onAutoProvisionChange={(checked) => {
form.setValue("autoProvision", checked); form.setValue("autoProvision", checked);
}} }}
orgId={orgId as string}
roleMappingMode={roleMappingMode} roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => { onRoleMappingModeChange={(data) => {
setRoleMappingMode(data); setRoleMappingMode(data);

View File

@@ -246,31 +246,123 @@ export default function Page() {
<PaidFeaturesAlert tiers={tierMatrix.orgOidc} /> <PaidFeaturesAlert tiers={tierMatrix.orgOidc} />
<fieldset <fieldset disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""}>
disabled={disabled} <SettingsContainer>
className={disabled ? "opacity-50 pointer-events-none" : ""} <SettingsSection>
> <SettingsSectionHeader>
<SettingsContainer> <SettingsSectionTitle>
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpCreateSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<OidcIdpProviderTypeSelect
value={form.watch("type")}
onTypeChange={(next) => {
applyOidcIdpProviderType(form.setValue, next);
}}
/>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("idpDisplayName")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Auto Provision Settings */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpAutoProvisionUsers")}
</SettingsSectionTitle>
<SettingsSectionDescription>
<IdpAutoProvisionUsersDescription />
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<AutoProvisionConfigWidget
autoProvision={
form.watch("autoProvision") as boolean
} // is this right?
onAutoProvisionChange={(checked) => {
form.setValue("autoProvision", checked);
}}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);
}}
roles={roles}
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={setFixedRoleNames}
mappingBuilderClaimPath={
mappingBuilderClaimPath
}
onMappingBuilderClaimPathChange={
setMappingBuilderClaimPath
}
mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={
setMappingBuilderRules
}
rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
{form.watch("type") === "google" && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("idpTitle")} {t("idpGoogleConfigurationTitle")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("idpCreateSettingsDescription")} {t("idpGoogleConfigurationDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<OidcIdpProviderTypeSelect
value={form.watch("type")}
onTypeChange={(next) => {
applyOidcIdpProviderType(
form.setValue,
next
);
}}
/>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
@@ -280,17 +372,43 @@ export default function Page() {
> >
<FormField <FormField
control={form.control} control={form.control}
name="name" name="clientId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("name")} {t("idpClientId")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t("idpDisplayName")} {t(
"idpGoogleClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpGoogleClientSecretDescription"
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -301,504 +419,350 @@ export default function Page() {
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)}
{/* Auto Provision Settings */} {form.watch("type") === "azure" && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("idpAutoProvisionUsers")} {t("idpAzureConfigurationTitle")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
<IdpAutoProvisionUsersDescription /> {t("idpAzureConfigurationDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<PaidFeaturesAlert <SettingsSectionForm>
tiers={tierMatrix.autoProvisioning} <Form {...form}>
/> <form
<Form {...form}> className="space-y-4"
<form id="create-idp-form"
className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}
id="create-idp-form" >
onSubmit={form.handleSubmit(onSubmit)} <FormField
> control={form.control}
<AutoProvisionConfigWidget name="tenantId"
autoProvision={ render={({ field }) => (
form.watch( <FormItem>
"autoProvision" <FormLabel>
) as boolean {t("idpTenantIdLabel")}
} // is this right? </FormLabel>
onAutoProvisionChange={(checked) => { <FormControl>
form.setValue( <Input {...field} />
"autoProvision", </FormControl>
checked <FormDescription>
); {t(
}} "idpAzureTenantIdDescription"
orgId={params.orgId as string} )}
roleMappingMode={roleMappingMode} </FormDescription>
onRoleMappingModeChange={(data) => { <FormMessage />
setRoleMappingMode(data); </FormItem>
}} )}
roles={roles} />
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={ <FormField
setFixedRoleNames control={form.control}
} name="clientId"
mappingBuilderClaimPath={ render={({ field }) => (
mappingBuilderClaimPath <FormItem>
} <FormLabel>
onMappingBuilderClaimPathChange={ {t("idpClientId")}
setMappingBuilderClaimPath </FormLabel>
} <FormControl>
mappingBuilderRules={ <Input {...field} />
mappingBuilderRules </FormControl>
} <FormDescription>
onMappingBuilderRulesChange={ {t(
setMappingBuilderRules "idpAzureClientIdDescription2"
} )}
rawExpression={rawRoleExpression} </FormDescription>
onRawExpressionChange={ <FormMessage />
setRawRoleExpression </FormItem>
} )}
orgMappingField={{ />
control: form.control,
name: "orgMapping" <FormField
}} control={form.control}
/> name="clientSecret"
</form> render={({ field }) => (
</Form> <FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAzureClientSecretDescription2"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)}
{form.watch("type") === "google" && ( {form.watch("type") === "oidc" && (
<SettingsSectionGrid cols={2}>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("idpGoogleConfigurationTitle")} {t("idpOidcConfigure")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("idpGoogleConfigurationDescription")} {t("idpOidcConfigureDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <Form {...form}>
<Form {...form}> <form
<form className="space-y-4"
className="space-y-4" id="create-idp-form"
id="create-idp-form" onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit( >
onSubmit <FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)} )}
> />
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpGoogleClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="clientSecret" name="clientSecret"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t("idpClientSecret")}
"idpClientSecret" </FormLabel>
)} <FormControl>
</FormLabel> <Input
<FormControl> type="password"
<Input {...field}
type="password" />
{...field} </FormControl>
/> <FormDescription>
</FormControl> {t(
<FormDescription> "idpClientSecretDescription"
{t( )}
"idpGoogleClientSecretDescription" </FormDescription>
)} <FormMessage />
</FormDescription> </FormItem>
<FormMessage /> )}
</FormItem> />
)}
/> <FormField
</form> control={form.control}
</Form> name="authUrl"
</SettingsSectionForm> render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)}
{form.watch("type") === "azure" && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("idpAzureConfigurationTitle")} {t("idpToken")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("idpAzureConfigurationDescription")} {t("idpTokenDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <Form {...form}>
<Form {...form}> <form
<form className="space-y-4"
className="space-y-4" id="create-idp-form"
id="create-idp-form" onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit( >
onSubmit <FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpJmespathLabel")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)} )}
> />
<FormField
control={form.control}
name="tenantId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpTenantIdLabel"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAzureTenantIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="clientId" name="emailPath"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("idpClientId")} {t(
</FormLabel> "idpJmespathEmailPathOptional"
<FormControl> )}
<Input {...field} /> </FormLabel>
</FormControl> <FormControl>
<FormDescription> <Input {...field} />
{t( </FormControl>
"idpAzureClientIdDescription2" <FormDescription>
)} {t(
</FormDescription> "idpJmespathEmailPathOptionalDescription"
<FormMessage /> )}
</FormItem> </FormDescription>
)} <FormMessage />
/> </FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="clientSecret" name="namePath"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t( {t(
"idpClientSecret" "idpJmespathNamePathOptional"
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
type="password" </FormControl>
{...field} <FormDescription>
/> {t(
</FormControl> "idpJmespathNamePathOptionalDescription"
<FormDescription> )}
{t( </FormDescription>
"idpAzureClientSecretDescription2" <FormMessage />
)} </FormItem>
</FormDescription> )}
<FormMessage /> />
</FormItem>
)} <FormField
/> control={form.control}
</form> name="scopes"
</Form> render={({ field }) => (
</SettingsSectionForm> <FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} </SettingsSectionGrid>
)}
</SettingsContainer>
{form.watch("type") === "oidc" && ( <div className="flex justify-end space-x-2 mt-8">
<SettingsSectionGrid cols={2}> <Button
<SettingsSection> type="button"
<SettingsSectionHeader> variant="outline"
<SettingsSectionTitle> onClick={() => {
{t("idpOidcConfigure")} router.push(`/${params.orgId}/settings/idp`);
</SettingsSectionTitle> }}
<SettingsSectionDescription> >
{t("idpOidcConfigureDescription")} {t("cancel")}
</SettingsSectionDescription> </Button>
</SettingsSectionHeader> <Button
<SettingsSectionBody> type="submit"
<Form {...form}> disabled={createLoading || disabled}
<form loading={createLoading}
className="space-y-4" onClick={() => {
id="create-idp-form" if (disabled) return;
onSubmit={form.handleSubmit( // log any issues with the form
onSubmit console.log(form.formState.errors);
)} form.handleSubmit(onSubmit)();
> }}
<FormField >
control={form.control} {t("idpSubmit")}
name="clientId" </Button>
render={({ field }) => ( </div>
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpClientSecret"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpToken")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTokenDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(
onSubmit
)}
>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathLabel"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${params.orgId}/settings/idp`);
}}
>
{t("cancel")}
</Button>
<Button
type="submit"
disabled={createLoading || disabled}
loading={createLoading}
onClick={() => {
if (disabled) return;
// log any issues with the form
console.log(form.formState.errors);
form.handleSubmit(onSubmit)();
}}
>
{t("idpSubmit")}
</Button>
</div>
</fieldset> </fieldset>
</> </>
); );

View File

@@ -0,0 +1,23 @@
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
export interface PolicyLayoutPageProps {
params: Promise<{ orgId: string }>;
children: React.ReactNode;
}
export default async function PolicyLayoutPage(props: PolicyLayoutPageProps) {
const params = await props.params;
let org: GetOrgResponse | null = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings`);
}
return <OrgProvider org={org}>{props.children}</OrgProvider>;
}

View File

@@ -0,0 +1,60 @@
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { redirect } from "next/navigation";
export interface EditPolicyPageProps {
params: Promise<{ niceId: string; orgId: string }>;
}
export default async function EditPolicyPage(props: EditPolicyPageProps) {
const params = await props.params;
const t = await getTranslations();
let policyResponse: GetResourcePolicyResponse | null = null;
try {
const res = await internal.get<
AxiosResponse<GetResourcePolicyResponse>
>(
`/org/${params.orgId}/resource-policy/${params.niceId}`,
await authCookieHeader()
);
policyResponse = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/policies/resource`);
}
if (!policyResponse) {
redirect(`/${params.orgId}/settings/policies/resource`);
}
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={t("resourcePolicySetting", {
policyName: policyResponse.name
})}
description={t("resourcePolicySettingDescription")}
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/policies/resource`}>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<ResourcePolicyProvider policy={policyResponse}>
<EditPolicyForm />
</ResourcePolicyProvider>
</>
);
}

View File

@@ -0,0 +1,35 @@
import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export interface CreateResourcePolicyPageProps {
params: Promise<{ orgId: string }>;
}
export default async function CreateResourcePolicyPage(
props: CreateResourcePolicyPageProps
) {
const params = await props.params;
const t = await getTranslations();
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={t("resourcePoliciesCreate")}
description={t("resourcePoliciesCreateDescription")}
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/policies/resource`}>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>
</div>
<CreatePolicyForm />
</>
);
}

View File

@@ -0,0 +1,68 @@
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
export interface ResourcePoliciesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
}
export default async function ResourcePoliciesPage(
props: ResourcePoliciesPageProps
) {
const params = await props.params;
const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
let org: GetOrgResponse | null = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
let policies: ListResourcePoliciesResponse["policies"] = [];
let pagination: ListResourcePoliciesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<
AxiosResponse<ListResourcePoliciesResponse>
>(
`/org/${params.orgId}/resource-policies?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
policies = responseData.policies;
pagination = responseData.pagination;
} catch (e) {}
return (
<>
<SettingsSectionTitle
title={t("resourcePoliciesTitle")}
description={t("resourcePoliciesDescription")}
/>
<ResourcePoliciesTable
policies={policies}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}

View File

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

View File

@@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { orgNavSections } from "@app/app/navigation"; import { orgNavSections } from "@app/app/navigation";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -48,13 +49,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const t = await getTranslations(); const t = await getTranslations();
try { try {
const getOrgUser = cache(() => const orgUser = await getCachedOrgUser(params.orgId, user.userId);
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${user.userId}`,
cookie
)
);
const orgUser = await getOrgUser();
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
throw new Error(t("userErrorNotAdminOrOwner")); throw new Error(t("userErrorNotAdminOrOwner"));

View File

@@ -96,10 +96,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
title: t("authentication"), title: t("authentication"),
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication` href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
}); });
navItems.push({ // navItems.push({
title: t("rules"), // title: t("rules"),
href: `/{orgId}/settings/resources/proxy/{niceId}/rules` // href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
}); // });
} }
return ( return (

View File

@@ -92,7 +92,13 @@ import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { toASCII } from "punycode"; import { toASCII } from "punycode";
import { useEffect, useMemo, useState, useCallback } from "react"; import {
useMemo,
useState,
useCallback,
useTransition,
useEffect
} from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -218,7 +224,7 @@ export default function Page() {
>([]); >([]);
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
const [createLoading, setCreateLoading] = useState(false); const [createLoading, startTransition] = useTransition();
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>(""); const [niceId, setNiceId] = useState<string>("");
@@ -328,7 +334,7 @@ export default function Page() {
id: "raw" as ResourceType, id: "raw" as ResourceType,
title: t("resourceRaw"), title: t("resourceRaw"),
description: description:
build == "saas" build === "saas"
? t("resourceRawDescriptionCloud") ? t("resourceRawDescriptionCloud")
: t("resourceRawDescription") : t("resourceRawDescription")
} }
@@ -473,8 +479,6 @@ export default function Page() {
); );
async function onSubmit() { async function onSubmit() {
setCreateLoading(true);
const baseData = baseForm.getValues(); const baseData = baseForm.getValues();
const isHttp = baseData.http; const isHttp = baseData.http;
@@ -610,8 +614,6 @@ export default function Page() {
) )
}); });
} }
setCreateLoading(false);
} }
useEffect(() => { useEffect(() => {
@@ -1465,7 +1467,7 @@ export default function Page() {
console.log(httpForm.getValues()); console.log(httpForm.getValues());
if (baseValid && settingsValid) { if (baseValid && settingsValid) {
onSubmit(); startTransition(onSubmit);
} }
}} }}
loading={createLoading} loading={createLoading}

View File

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

View File

@@ -11,6 +11,7 @@ import {
CreditCard, CreditCard,
Fingerprint, Fingerprint,
Globe, Globe,
GlobeIcon,
GlobeLock, GlobeLock,
KeyRound, KeyRound,
Laptop, Laptop,
@@ -22,6 +23,7 @@ import {
ScanEye, ScanEye,
Server, Server,
Settings, Settings,
ShieldIcon,
SquareMousePointer, SquareMousePointer,
TicketCheck, TicketCheck,
Unplug, Unplug,
@@ -99,7 +101,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains", href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" /> icon: <Globe className="size-4 flex-none" />
}, },
...(build == "saas" ...(build === "saas"
? [ ? [
{ {
title: "sidebarRemoteExitNodes", title: "sidebarRemoteExitNodes",
@@ -134,6 +136,24 @@ export const orgNavSections = (
} }
] ]
}, },
...(build !== "oss"
? [
{
title: "sidebarPolicies",
icon: <ShieldIcon className="size-4 flex-none" />,
items: [
{
title: "sidebarResourcePolicies",
href: "/{orgId}/settings/policies/resource",
icon: (
<GlobeIcon className="size-4 flex-none" />
)
}
]
}
]
: []),
// PaidFeaturesAlert // PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" || build === "saas" ||
@@ -212,22 +232,16 @@ export const orgNavSections = (
title: "sidebarManagement", title: "sidebarManagement",
icon: <Building2 className="size-4 flex-none" />, icon: <Building2 className="size-4 flex-none" />,
items: [ items: [
...(!env?.flags.disableEnterpriseFeatures {
? [ title: "sidebarAlerting",
{ href: "/{orgId}/settings/alerting",
title: "sidebarAlerting", icon: <BellRing className="size-4 flex-none" />
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: "sidebarProvisioning",
href: "/{orgId}/settings/provisioning",
icon: <Boxes className="size-4 flex-none" />
}
]
: []),
{ {
title: "sidebarBluePrints", title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints", href: "/{orgId}/settings/blueprints",

View File

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

View File

@@ -28,15 +28,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build"; import { build } from "@server/build";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export type AuthPageCustomizationProps = { export type AuthPageCustomizationProps = {
orgId: string; orgId: string;

View File

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

View File

@@ -840,16 +840,12 @@ export function InternalResourceForm({
modeCidrKey modeCidrKey
) )
}, },
...(!disableEnterpriseFeatures {
? [ value: "http",
{ label: t(
value: "http" as const, modeHttpKey
label: t( )
modeHttpKey }
)
}
]
: [])
]; ];
return ( return (
<FormItem> <FormItem>
@@ -1129,6 +1125,30 @@ export function InternalResourceForm({
}} }}
/> />
</div> </div>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
disabled={
httpSectionDisabled
}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<FormField <FormField
control={form.control} control={form.control}

View File

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

View File

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

View File

@@ -193,22 +193,17 @@ export default function ProxyResourcesTable({
}); });
}; };
const deleteResource = (resourceId: number) => { const deleteResource = async (resourceId: number) => {
api.delete(`/resource/${resourceId}`) await api.delete(`/resource/${resourceId}`).catch((e) => {
.catch((e) => { console.error(t("resourceErrorDelte"), e);
console.error(t("resourceErrorDelte"), e); toast({
toast({ variant: "destructive",
variant: "destructive", title: t("resourceErrorDelte"),
title: t("resourceErrorDelte"), description: formatAxiosError(e, t("resourceErrorDelte"))
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
}); });
});
router.refresh();
setIsDeleteModalOpen(false);
}; };
async function toggleResourceEnabled(val: boolean, resourceId: number) { async function toggleResourceEnabled(val: boolean, resourceId: number) {
@@ -770,7 +765,11 @@ export default function ProxyResourcesTable({
</div> </div>
} }
buttonText={t("resourceDeleteConfirm")} buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)} onConfirm={async () =>
startTransition(() =>
deleteResource(selectedResource!.id)
)
}
string={selectedResource.name} string={selectedResource.name}
title={t("resourceDelete")} title={t("resourceDelete")}
/> />

View File

@@ -0,0 +1,311 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type {
AttachedResource,
ListResourcePoliciesResponse
} from "@server/routers/resource/types";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowRight,
ChevronDown,
MoreHorizontal,
Waypoints
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import { Button } from "./ui/button";
import { ControlledDataTable } from "./ui/controlled-data-table";
import type { ExtendedColumnDef } from "./ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "./ui/dropdown-menu";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number];
export type ResourcePoliciesTableProps = {
policies: Array<ResourcePolicyRow>;
orgId: string;
pagination: PaginationState;
rowCount: number;
};
export function ResourcePoliciesTable({
policies,
orgId,
pagination,
rowCount
}: ResourcePoliciesTableProps) {
const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResourcePolicy, setSelectedResourcePolicy] =
useState<ResourcePolicyRow | null>(null);
const deleteResourcePolicy = async (resourcePolicyId: number) => {
await api
.delete(`/resource-policy/${resourcePolicyId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
};
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
function ResourceListCell({
resources
}: {
resources?: AttachedResource[];
}) {
if (!resources || resources.length === 0) {
return (
<div
id="LOOK_FOR_ME"
className="flex items-center gap-2 text-muted-foreground"
>
<Waypoints className="size-4 flex-none" />
<span className="text-sm">
{t("resourcePoliciesAttachedResourcesEmpty")}
</span>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<Waypoints className="size-4 flex-none" />
<span className="text-sm">
{t("resourcePoliciesAttachedResources", {
count: resources.length
})}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-70">
{resources.map((resource) => (
<DropdownMenuItem
key={resource.resourceId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
{resource.name}
</div>
<span
className={`capitalize text-muted-foreground`}
>
{resource.fullDomain}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
const proxyColumns: ExtendedColumnDef<ResourcePolicyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => <span>{row.original.name}</span>
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{
id: "resources",
accessorKey: "resources",
friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"),
header: () => (
<span className="p-3">
{t("resourcePoliciesAttachedResourcesColumnTitle")}
</span>
),
cell: ({ row }) => {
return <ResourceListCell resources={row.original.resources} />;
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const policyRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResourcePolicy(policyRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return (
<>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.ResourcePolicies]}
/>
{selectedResourcePolicy && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResourcePolicy(null);
}}
dialog={
<div className="space-y-2">
<p>{t("resourcePolicyQuestionRemove")}</p>
<p>{t("resourcePolicyMessageRemove")}</p>
</div>
}
buttonText={t("resourcePolicyDeleteConfirm")}
onConfirm={async () =>
deleteResourcePolicy(
selectedResourcePolicy.resourcePolicyId
)
}
string={selectedResourcePolicy.name}
title={t("resourcePolicyDelete")}
/>
)}
<ControlledDataTable
columns={proxyColumns}
rows={policies}
tableId="resource-policies"
searchPlaceholder={t("resourcePoliciesSearch")}
pagination={pagination}
rowCount={rowCount}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/policies/resource/create`
)
)
}
addButtonText={t("resourcePoliciesAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility
columnVisibility={{ niceId: false }}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}

View File

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

View File

@@ -61,12 +61,19 @@ export function SettingsSectionBody({
} }
export function SettingsSectionFooter({ export function SettingsSectionFooter({
children children,
className
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string;
}) { }) {
return ( return (
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6"> <div
className={cn(
"flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6",
className
)}
>
{children} {children}
</div> </div>
); );

View File

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

View File

@@ -25,11 +25,15 @@ export function StrategySelect<TValue extends string>({
value: controlledValue, value: controlledValue,
defaultValue, defaultValue,
onChange, onChange,
cols cols = 1
}: StrategySelectProps<TValue>) { }: StrategySelectProps<TValue>) {
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue); const [uncontrolledSelected, setUncontrolledSelected] = useState<
TValue | undefined
>(defaultValue);
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined;
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected; const selected = isControlled
? (controlledValue ?? undefined)
: uncontrolledSelected;
return ( return (
<RadioGroup <RadioGroup
@@ -39,7 +43,11 @@ export function StrategySelect<TValue extends string>({
if (!isControlled) setUncontrolledSelected(typedValue); if (!isControlled) setUncontrolledSelected(typedValue);
onChange?.(typedValue); onChange?.(typedValue);
}} }}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`} style={{
// @ts-expect-error
"--cols": `repeat(${cols}, 1fr)`
}}
className="grid md:grid-cols-(--cols) gap-4"
> >
{options.map((option: StrategyOption<TValue>) => ( {options.map((option: StrategyOption<TValue>) => (
<label <label

View File

@@ -53,12 +53,10 @@ export default function UptimeAlertSection({
days = 90 days = 90
}: UptimeAlertSectionProps) { }: UptimeAlertSectionProps) {
const t = useTranslations(); const t = useTranslations();
const envContext = useEnvContext(); const api = createApiClient(useEnvContext());
const api = createApiClient(envContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules); const isPaid = isPaidUser(tierMatrix.alertingRules);
const { env } = envContext;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState( const [name, setName] = useState(
@@ -178,9 +176,7 @@ export default function UptimeAlertSection({
{t("uptimeSectionDescription", { days })} {t("uptimeSectionDescription", { days })}
</SettingsSectionDescription> </SettingsSectionDescription>
</div> </div>
{!env.flags.disableEnterpriseFeatures {alertButton}
? alertButton
: null}
</div> </div>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>

View File

@@ -23,6 +23,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
onSearch: (query: string) => void; onSearch: (query: string) => void;
ref?: Ref<HTMLButtonElement>; ref?: Ref<HTMLButtonElement>;
disabled?: boolean; disabled?: boolean;
lockedIds?: Set<string>;
}; };
export function MultiSelectContent<T extends TagValue>({ export function MultiSelectContent<T extends TagValue>({
@@ -32,7 +33,8 @@ export function MultiSelectContent<T extends TagValue>({
value, value,
options, options,
onSearch, onSearch,
onChange onChange,
lockedIds
}: MultiSelectTagsProps<T>) { }: MultiSelectTagsProps<T>) {
const t = useTranslations(); const t = useTranslations();
const selectedValues = new Set(value.map((v) => v.id)); const selectedValues = new Set(value.map((v) => v.id));
@@ -48,33 +50,38 @@ export function MultiSelectContent<T extends TagValue>({
{emptyPlaceholder ?? t("noResults")} {emptyPlaceholder ?? t("noResults")}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => {
<CommandItem const isLocked = lockedIds?.has(option.id);
value={option.id} return (
key={option.id} <CommandItem
onSelect={() => { value={option.id}
let newValues = []; key={option.id}
if (selectedValues.has(option.id)) { disabled={isLocked}
newValues = value.filter( onSelect={() => {
(v) => v.id !== option.id if (isLocked) return;
); let newValues = [];
} else { if (selectedValues.has(option.id)) {
newValues = [...value, option]; newValues = value.filter(
} (v) => v.id !== option.id
onChange(newValues); );
}} } else {
> newValues = [...value, option];
<CheckIcon }
className={cn( onChange(newValues);
"mr-2 h-4 w-4", }}
selectedValues.has(option.id) >
? "opacity-100" <CheckIcon
: "opacity-0" className={cn(
)} "mr-2 h-4 w-4",
/> selectedValues.has(option.id)
{`${option.text}`} ? "opacity-100"
</CommandItem> : "opacity-0"
))} )}
/>
{`${option.text}`}
</CommandItem>
);
})}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>

View File

@@ -5,7 +5,7 @@ import {
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ChevronDownIcon, XIcon } from "lucide-react"; import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
import { import {
type MultiSelectTagsProps, type MultiSelectTagsProps,
type TagValue, type TagValue,
@@ -16,10 +16,12 @@ export interface MultiSelectInputProps<
T extends TagValue T extends TagValue
> extends MultiSelectTagsProps<T> { > extends MultiSelectTagsProps<T> {
buttonText?: string; buttonText?: string;
lockedIds?: Set<string>;
} }
export function MultiSelectTagInput<T extends TagValue>({ export function MultiSelectTagInput<T extends TagValue>({
buttonText, buttonText,
lockedIds,
...props ...props
}: MultiSelectInputProps<T>) { }: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id)); const selectedValues = new Set(props.value.map((v) => v.id));
@@ -52,46 +54,63 @@ export function MultiSelectTagInput<T extends TagValue>({
"overflow-x-auto" "overflow-x-auto"
)} )}
> >
{props.value.map((option) => ( {props.value.map((option) => {
<span const isLocked = lockedIds?.has(option.id);
key={option.id} return (
className={cn( <span
"bg-muted-foreground/10 font-normal text-foreground rounded-sm", key={option.id}
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5" className={cn(
)} "bg-muted-foreground/10 font-normal text-foreground rounded-sm",
onClick={(e) => e.stopPropagation()} "py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
> isLocked && "opacity-60"
{option.text} )}
<button onClick={(e) => e.stopPropagation()}
className="p-0.5 flex-none cursor-pointer"
type="button"
onClick={(e) => {
e.stopPropagation();
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = props.value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [
...props.value,
option
];
}
props.onChange(newValues);
}}
> >
<XIcon className="size-3.5" /> {option.text}
</button> {isLocked ? (
</span> <span className="p-0.5 flex-none">
))} <LockIcon className="size-3" />
</span>
) : (
<button
className="p-0.5 flex-none cursor-pointer"
type="button"
onClick={(e) => {
e.stopPropagation();
let newValues = [];
if (
selectedValues.has(
option.id
)
) {
newValues =
props.value.filter(
(v) =>
v.id !==
option.id
);
} else {
newValues = [
...props.value,
option
];
}
props.onChange(newValues);
}}
>
<XIcon className="size-3.5" />
</button>
)}
</span>
);
})}
<span className="pl-1 font-normal">{buttonText}</span> <span className="pl-1 font-normal">{buttonText}</span>
</span> </span>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" /> <ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0"> <PopoverContent className="p-0">
<MultiSelectContent {...props} /> <MultiSelectContent {...props} lockedIds={lockedIds} />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@@ -0,0 +1,521 @@
"use client";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import z from "zod";
import { createPolicySchema, type PolicyFormValues } from ".";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { cn } from "@app/lib/cn";
import { Binary, Bot, Key, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { type UseFormReturn, useForm, useWatch } from "react-hook-form";
// ─── CreatePolicyAuthMethodsSectionForm ───────────────────────────────────────
const setPasswordSchema = z.object({
password: z.string().min(4).max(100)
});
const setPincodeSchema = z.object({
pincode: z.string().length(6)
});
const setHeaderAuthSchema = z.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100),
extendedCompatibility: z.boolean()
});
export type CreatePolicyAuthMethodsSectionFormProps = {
form: UseFormReturn<PolicyFormValues, any, any>;
};
export function CreatePolicyAuthMethodsSectionForm({
form: parentForm
}: CreatePolicyAuthMethodsSectionFormProps) {
const t = useTranslations();
const [isExpanded, setIsExpanded] = useState(false);
const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false);
const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false);
const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false);
const form = useForm({
resolver: zodResolver(
createPolicySchema.pick({
password: true,
pincode: true,
headerAuth: true
})
),
defaultValues: {
password: null,
pincode: null,
headerAuth: null
}
});
useEffect(() => {
const subscription = form.watch((values) => {
parentForm.setValue("password", values.password as any);
parentForm.setValue("pincode", values.pincode as any);
parentForm.setValue("headerAuth", values.headerAuth as any);
});
return () => subscription.unsubscribe();
}, [form, parentForm]);
const password = useWatch({
control: form.control,
name: "password"
});
const pincode = useWatch({
control: form.control,
name: "pincode"
});
const headerAuth = useWatch({
control: form.control,
name: "headerAuth"
});
const passwordForm = useForm({
resolver: zodResolver(setPasswordSchema),
defaultValues: { password: "" }
});
const pincodeForm = useForm({
resolver: zodResolver(setPincodeSchema),
defaultValues: { pincode: "" }
});
const headerAuthForm = useForm({
resolver: zodResolver(setHeaderAuthSchema),
defaultValues: { user: "", password: "", extendedCompatibility: true }
});
if (!isExpanded) {
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceAuthMethods")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicyAuthMethodsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Button
type="button"
variant="outline"
onClick={() => setIsExpanded(true)}
>
<Plus className="mr-2 h-4 w-4" />
{t("resourcePolicyAuthMethodAdd")}
</Button>
</SettingsSectionBody>
</SettingsSection>
);
}
return (
<>
{/* Password Credenza */}
<Credenza
open={isSetPasswordOpen}
onOpenChange={(val) => {
setIsSetPasswordOpen(val);
if (!val) passwordForm.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("resourcePasswordSetupTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("resourcePasswordSetupTitleDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...passwordForm}>
<form
onSubmit={passwordForm.handleSubmit((data) => {
form.setValue("password", data);
setIsSetPasswordOpen(false);
passwordForm.reset();
})}
className="space-y-4"
id="set-password-form"
>
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button type="submit" form="set-password-form">
{t("resourcePasswordSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{/* Pincode Credenza */}
<Credenza
open={isSetPincodeOpen}
onOpenChange={(val) => {
setIsSetPincodeOpen(val);
if (!val) pincodeForm.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("resourcePincodeSetupTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("resourcePincodeSetupTitleDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...pincodeForm}>
<form
onSubmit={pincodeForm.handleSubmit((data) => {
form.setValue("pincode", data);
setIsSetPincodeOpen(false);
pincodeForm.reset();
})}
className="space-y-4"
id="set-pincode-form"
>
<FormField
control={pincodeForm.control}
name="pincode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("resourcePincode")}
</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
autoComplete="false"
maxLength={6}
{...field}
>
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
obscured
/>
<InputOTPSlot
index={1}
obscured
/>
<InputOTPSlot
index={2}
obscured
/>
<InputOTPSlot
index={3}
obscured
/>
<InputOTPSlot
index={4}
obscured
/>
<InputOTPSlot
index={5}
obscured
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button type="submit" form="set-pincode-form">
{t("resourcePincodeSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{/* Header Auth Credenza */}
<Credenza
open={isSetHeaderAuthOpen}
onOpenChange={(val) => {
setIsSetHeaderAuthOpen(val);
if (!val) headerAuthForm.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("resourceHeaderAuthSetupTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("resourceHeaderAuthSetupTitleDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...headerAuthForm}>
<form
onSubmit={headerAuthForm.handleSubmit(
(data) => {
form.setValue("headerAuth", data);
setIsSetHeaderAuthOpen(false);
headerAuthForm.reset();
}
)}
className="space-y-4"
id="set-header-auth-form"
>
<FormField
control={headerAuthForm.control}
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>{t("user")}</FormLabel>
<FormControl>
<Input
autoComplete="off"
type="text"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={headerAuthForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={headerAuthForm.control}
name="extendedCompatibility"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="header-auth-compatibility-toggle"
label={t(
"headerAuthCompatibility"
)}
info={t(
"headerAuthCompatibilityInfo"
)}
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button type="submit" form="set-header-auth-form">
{t("resourceHeaderAuthSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceAuthMethods")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourcePolicyAuthMethodsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{/* Password row */}
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
<div
className={cn("flex items-center text-sm space-x-2", password && "text-green-500")}
>
<Key size="14" />
<span>
{t("resourcePasswordProtection", {
status: password
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={
password
? () => form.setValue("password", null)
: () => setIsSetPasswordOpen(true)
}
>
{password
? t("passwordRemove")
: t("passwordAdd")}
</Button>
</div>
{/* Pincode row */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={cn("flex items-center space-x-2 text-sm", pincode && "text-green-500")}
>
<Binary size="14" />
<span>
{t("resourcePincodeProtection", {
status: pincode
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={
pincode
? () => form.setValue("pincode", null)
: () => setIsSetPincodeOpen(true)
}
>
{pincode ? t("pincodeRemove") : t("pincodeAdd")}
</Button>
</div>
{/* Header auth row */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={cn("flex items-center space-x-2 text-sm", headerAuth && "text-green-500")}
>
<Bot size="14" />
<span>
{headerAuth
? t(
"resourceHeaderAuthProtectionEnabled"
)
: t(
"resourceHeaderAuthProtectionDisabled"
)}
</span>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={
headerAuth
? () =>
form.setValue("headerAuth", null)
: () => setIsSetHeaderAuthOpen(true)
}
>
{headerAuth
? t("headerAuthRemove")
: t("headerAuthAdd")}
</Button>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</>
);
}

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