diff --git a/bruno/API Keys/Create API Key.bru b/bruno/API Keys/Create API Key.bru deleted file mode 100644 index 009b4b049..000000000 --- a/bruno/API Keys/Create API Key.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Create API Key - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/api-key - body: json - auth: inherit -} - -body:json { - { - "isRoot": true - } -} diff --git a/bruno/API Keys/Delete API Key.bru b/bruno/API Keys/Delete API Key.bru deleted file mode 100644 index 9285f7889..000000000 --- a/bruno/API Keys/Delete API Key.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Delete API Key - type: http - seq: 2 -} - -delete { - url: http://localhost:3000/api/v1/api-key/dm47aacqxxn3ubj - body: none - auth: inherit -} diff --git a/bruno/API Keys/List API Key Actions.bru b/bruno/API Keys/List API Key Actions.bru deleted file mode 100644 index ae5b721e1..000000000 --- a/bruno/API Keys/List API Key Actions.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List API Key Actions - type: http - seq: 6 -} - -get { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions - body: none - auth: inherit -} diff --git a/bruno/API Keys/List Org API Keys.bru b/bruno/API Keys/List Org API Keys.bru deleted file mode 100644 index 468e964b9..000000000 --- a/bruno/API Keys/List Org API Keys.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List Org API Keys - type: http - seq: 4 -} - -get { - url: http://localhost:3000/api/v1/org/home-lab/api-keys - body: none - auth: inherit -} diff --git a/bruno/API Keys/List Root API Keys.bru b/bruno/API Keys/List Root API Keys.bru deleted file mode 100644 index 8ef31b68c..000000000 --- a/bruno/API Keys/List Root API Keys.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: List Root API Keys - type: http - seq: 3 -} - -get { - url: http://localhost:3000/api/v1/root/api-keys - body: none - auth: inherit -} diff --git a/bruno/API Keys/Set API Key Actions.bru b/bruno/API Keys/Set API Key Actions.bru deleted file mode 100644 index 54a35c438..000000000 --- a/bruno/API Keys/Set API Key Actions.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Set API Key Actions - type: http - seq: 5 -} - -post { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions - body: json - auth: inherit -} - -body:json { - { - "actionIds": ["listSites"] - } -} diff --git a/bruno/API Keys/Set API Key Orgs.bru b/bruno/API Keys/Set API Key Orgs.bru deleted file mode 100644 index 3f0676c5b..000000000 --- a/bruno/API Keys/Set API Key Orgs.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Set API Key Orgs - type: http - seq: 7 -} - -post { - url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/orgs - body: json - auth: inherit -} - -body:json { - { - "orgIds": ["home-lab"] - } -} diff --git a/bruno/API Keys/folder.bru b/bruno/API Keys/folder.bru deleted file mode 100644 index bb8cd5c73..000000000 --- a/bruno/API Keys/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: API Keys -} diff --git a/bruno/Auth/2fa-disable.bru b/bruno/Auth/2fa-disable.bru deleted file mode 100644 index c98539c73..000000000 --- a/bruno/Auth/2fa-disable.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: 2fa-disable - type: http - seq: 6 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/disable - body: json - auth: none -} - -body:json { - { - "password": "aaaaa-1A", - "code": "377289" - } -} diff --git a/bruno/Auth/2fa-enable.bru b/bruno/Auth/2fa-enable.bru deleted file mode 100644 index a3a01d177..000000000 --- a/bruno/Auth/2fa-enable.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: 2fa-enable - type: http - seq: 4 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/enable - body: json - auth: none -} - -body:json { - { - "code": "374138" - } -} diff --git a/bruno/Auth/2fa-request.bru b/bruno/Auth/2fa-request.bru deleted file mode 100644 index fcf0c9862..000000000 --- a/bruno/Auth/2fa-request.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: 2fa-request - type: http - seq: 5 -} - -post { - url: http://localhost:3000/api/v1/auth/2fa/request - body: json - auth: none -} - -body:json { - { - "password": "aaaaa-1A" - } -} diff --git a/bruno/Auth/change-password.bru b/bruno/Auth/change-password.bru deleted file mode 100644 index 7d1c707e5..000000000 --- a/bruno/Auth/change-password.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: change-password - type: http - seq: 9 -} - -post { - url: http://localhost:3000/api/v1/auth/change-password - body: json - auth: none -} - -body:json { - { - "oldPassword": "", - "newPassword": "" - } -} diff --git a/bruno/Auth/login.bru b/bruno/Auth/login.bru deleted file mode 100644 index 3825a2525..000000000 --- a/bruno/Auth/login.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: login - type: http - seq: 1 -} - -post { - url: http://localhost:3000/api/v1/auth/login - body: json - auth: none -} - -body:json { - { - "email": "admin@fosrl.io", - "password": "Password123!" - } -} diff --git a/bruno/Auth/logout.bru b/bruno/Auth/logout.bru deleted file mode 100644 index 623cd47fe..000000000 --- a/bruno/Auth/logout.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: logout - type: http - seq: 3 -} - -post { - url: http://localhost:4000/api/v1/auth/logout - body: none - auth: none -} diff --git a/bruno/Auth/reset-password-request.bru b/bruno/Auth/reset-password-request.bru deleted file mode 100644 index 29c3b89d1..000000000 --- a/bruno/Auth/reset-password-request.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: reset-password-request - type: http - seq: 10 -} - -post { - url: http://localhost:3000/api/v1/auth/reset-password/request - body: json - auth: none -} - -body:json { - { - "email": "milo@pangolin.net" - } -} diff --git a/bruno/Auth/reset-password.bru b/bruno/Auth/reset-password.bru deleted file mode 100644 index 8d567b164..000000000 --- a/bruno/Auth/reset-password.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: reset-password - type: http - seq: 11 -} - -post { - url: http://localhost:3000/api/v1/auth/reset-password - body: json - auth: none -} - -body:json { - { - "token": "3uhsbom72dwdhboctwrtntyd6jrlg4jtf5oaxy4k", - "newPassword": "aaaaa-1A", - "code": "6irqCGR3" - } -} diff --git a/bruno/Auth/signup.bru b/bruno/Auth/signup.bru deleted file mode 100644 index bec59235e..000000000 --- a/bruno/Auth/signup.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: signup - type: http - seq: 2 -} - -put { - url: http://localhost:3000/api/v1/auth/signup - body: json - auth: none -} - -body:json { - { - "email": "numbat@pangolin.net", - "password": "Password123!" - } -} diff --git a/bruno/Auth/verify-email-request.bru b/bruno/Auth/verify-email-request.bru deleted file mode 100644 index 72189d1b2..000000000 --- a/bruno/Auth/verify-email-request.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: verify-email-request - type: http - seq: 8 -} - -post { - url: http://localhost:3000/api/v1/auth/verify-email/request - body: none - auth: none -} diff --git a/bruno/Auth/verify-email.bru b/bruno/Auth/verify-email.bru deleted file mode 100644 index a06a7108c..000000000 --- a/bruno/Auth/verify-email.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: verify-email - type: http - seq: 7 -} - -post { - url: http://localhost:3000/api/v1/auth/verify-email - body: json - auth: none -} - -body:json { - { - "code": "50317187" - } -} diff --git a/bruno/Auth/verify-user.bru b/bruno/Auth/verify-user.bru deleted file mode 100644 index 38955449d..000000000 --- a/bruno/Auth/verify-user.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: verify-user - type: http - seq: 4 -} - -get { - url: http://localhost:3001/api/v1/badger/verify-user?sessionId=mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e - body: none - auth: none -} - -params:query { - sessionId: mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e -} diff --git a/bruno/Clients/createClient.bru b/bruno/Clients/createClient.bru deleted file mode 100644 index 7577bb280..000000000 --- a/bruno/Clients/createClient.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: createClient - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/site/1/client - body: json - auth: none -} - -body:json { - { - "siteId": 1, - "name": "test", - "type": "olm", - "subnet": "100.90.129.4/30", - "olmId": "029yzunhx6nh3y5", - "secret": "l0ymp075y3d4rccb25l6sqpgar52k09etunui970qq5gj7x6" - } -} diff --git a/bruno/Clients/pickClientDefaults.bru b/bruno/Clients/pickClientDefaults.bru deleted file mode 100644 index 61509c112..000000000 --- a/bruno/Clients/pickClientDefaults.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: pickClientDefaults - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/site/1/pick-client-defaults - body: none - auth: none -} diff --git a/bruno/IDP/Create OIDC Provider.bru b/bruno/IDP/Create OIDC Provider.bru deleted file mode 100644 index 23e807cf9..000000000 --- a/bruno/IDP/Create OIDC Provider.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: Create OIDC Provider - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/org/home-lab/idp/oidc - body: json - auth: inherit -} - -body:json { - { - "clientId": "JJoSvHCZcxnXT2sn6CObj6a21MuKNRXs3kN5wbys", - "clientSecret": "2SlGL2wOGgMEWLI9yUuMAeFxre7qSNJVnXMzyepdNzH1qlxYnC4lKhhQ6a157YQEkYH3vm40KK4RCqbYiF8QIweuPGagPX3oGxEj2exwutoXFfOhtq4hHybQKoFq01Z3", - "authUrl": "http://localhost:9000/application/o/authorize/", - "tokenUrl": "http://localhost:9000/application/o/token/", - "scopes": ["email", "openid", "profile"], - "userIdentifier": "email" - } -} diff --git a/bruno/IDP/Generate OIDC URL.bru b/bruno/IDP/Generate OIDC URL.bru deleted file mode 100644 index 90443096f..000000000 --- a/bruno/IDP/Generate OIDC URL.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Generate OIDC URL - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1 - body: none - auth: inherit -} diff --git a/bruno/IDP/folder.bru b/bruno/IDP/folder.bru deleted file mode 100644 index fc1369159..000000000 --- a/bruno/IDP/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: IDP -} diff --git a/bruno/Internal/Traefik Config.bru b/bruno/Internal/Traefik Config.bru deleted file mode 100644 index 9fc1c1dcb..000000000 --- a/bruno/Internal/Traefik Config.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Traefik Config - type: http - seq: 1 -} - -get { - url: http://localhost:3001/api/v1/traefik-config - body: none - auth: inherit -} diff --git a/bruno/Internal/folder.bru b/bruno/Internal/folder.bru deleted file mode 100644 index 702931ec4..000000000 --- a/bruno/Internal/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Internal -} diff --git a/bruno/Newt/Create Newt.bru b/bruno/Newt/Create Newt.bru deleted file mode 100644 index 56baf89bd..000000000 --- a/bruno/Newt/Create Newt.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Create Newt - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/newt - body: none - auth: none -} diff --git a/bruno/Newt/Get Token.bru b/bruno/Newt/Get Token.bru deleted file mode 100644 index 93d91cc5d..000000000 --- a/bruno/Newt/Get Token.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: Get Token - type: http - seq: 1 -} - -get { - url: http://localhost:3000/api/v1/auth/newt/get-token - body: json - auth: none -} - -body:json { - { - "newtId": "o0d4rdxq3stnz7b", - "secret": "sy7l09fnaesd03iwrfp9m3qf0ryn19g0zf3dqieaazb4k7vk" - } -} diff --git a/bruno/Olm/createOlm.bru b/bruno/Olm/createOlm.bru deleted file mode 100644 index ca755dea8..000000000 --- a/bruno/Olm/createOlm.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: createOlm - type: http - seq: 1 -} - -put { - url: http://localhost:3000/api/v1/olm - body: none - auth: inherit -} - -settings { - encodeUrl: true -} diff --git a/bruno/Olm/folder.bru b/bruno/Olm/folder.bru deleted file mode 100644 index d245e6d1c..000000000 --- a/bruno/Olm/folder.bru +++ /dev/null @@ -1,8 +0,0 @@ -meta { - name: Olm - seq: 15 -} - -auth { - mode: inherit -} diff --git a/bruno/Orgs/Check Id.bru b/bruno/Orgs/Check Id.bru deleted file mode 100644 index 17b63953c..000000000 --- a/bruno/Orgs/Check Id.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Check Id - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/org/checkId - body: none - auth: none -} diff --git a/bruno/Orgs/listOrgs.bru b/bruno/Orgs/listOrgs.bru deleted file mode 100644 index 89c34d0cb..000000000 --- a/bruno/Orgs/listOrgs.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listOrgs - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Remote Exit Node/createRemoteExitNode.bru b/bruno/Remote Exit Node/createRemoteExitNode.bru deleted file mode 100644 index 1c749a311..000000000 --- a/bruno/Remote Exit Node/createRemoteExitNode.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: createRemoteExitNode - type: http - seq: 1 -} - -put { - url: http://localhost:4000/api/v1/org/org_i21aifypnlyxur2/remote-exit-node - body: none - auth: none -} diff --git a/bruno/Resources/listResourcesByOrg.bru b/bruno/Resources/listResourcesByOrg.bru deleted file mode 100644 index 6efce1b20..000000000 --- a/bruno/Resources/listResourcesByOrg.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listResourcesByOrg - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Resources/listResourcesBySite.bru b/bruno/Resources/listResourcesBySite.bru deleted file mode 100644 index 81c9cf99b..000000000 --- a/bruno/Resources/listResourcesBySite.bru +++ /dev/null @@ -1,16 +0,0 @@ -meta { - name: listResourcesBySite - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/site/1/resources?limit=10&offset=0 - body: none - auth: none -} - -params:query { - limit: 10 - offset: 0 -} diff --git a/bruno/Sites/Get Site.bru b/bruno/Sites/Get Site.bru deleted file mode 100644 index fc2f7e62b..000000000 --- a/bruno/Sites/Get Site.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Get Site - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/org/test/sites/mexican-mole-lizard-windy - body: none - auth: none -} diff --git a/bruno/Sites/listSites.bru b/bruno/Sites/listSites.bru deleted file mode 100644 index b7912330a..000000000 --- a/bruno/Sites/listSites.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: listSites - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/Targets/listTargets.bru b/bruno/Targets/listTargets.bru deleted file mode 100644 index 7981eb453..000000000 --- a/bruno/Targets/listTargets.bru +++ /dev/null @@ -1,16 +0,0 @@ -meta { - name: listTargets - type: http - seq: 1 -} - -get { - url: http://localhost:3000/api/v1/resource/web.main.localhost/targets?limit=10&offset=0 - body: none - auth: none -} - -params:query { - limit: 10 - offset: 0 -} diff --git a/bruno/Test.bru b/bruno/Test.bru deleted file mode 100644 index 16286ec8c..000000000 --- a/bruno/Test.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Test - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1 - body: none - auth: inherit -} diff --git a/bruno/Traefik/traefik-config.bru b/bruno/Traefik/traefik-config.bru deleted file mode 100644 index a50b7aa15..000000000 --- a/bruno/Traefik/traefik-config.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: traefik-config - type: http - seq: 1 -} - -get { - url: http://localhost:3001/api/v1/traefik-config - body: none - auth: none -} diff --git a/bruno/Users/adminListUsers.bru b/bruno/Users/adminListUsers.bru deleted file mode 100644 index cdc410956..000000000 --- a/bruno/Users/adminListUsers.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: adminListUsers - type: http - seq: 2 -} - -get { - url: http://localhost:3000/api/v1/users - body: none - auth: none -} diff --git a/bruno/Users/adminRemoveUser.bru b/bruno/Users/adminRemoveUser.bru deleted file mode 100644 index 9e9f35079..000000000 --- a/bruno/Users/adminRemoveUser.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: adminRemoveUser - type: http - seq: 3 -} - -delete { - url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4 - body: none - auth: none -} diff --git a/bruno/Users/getUser.bru b/bruno/Users/getUser.bru deleted file mode 100644 index d86372527..000000000 --- a/bruno/Users/getUser.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: getUser - type: http - seq: 1 -} - -get { - url: - body: none - auth: none -} diff --git a/bruno/bruno.json b/bruno/bruno.json deleted file mode 100644 index f19d936a8..000000000 --- a/bruno/bruno.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "1", - "name": "Pangolin", - "type": "collection", - "ignore": [ - "node_modules", - ".git" - ], - "presets": { - "requestType": "http", - "requestUrl": "http://localhost:3000/api/v1" - } -} \ No newline at end of file diff --git a/messages/bg-BG.json b/messages/bg-BG.json index d429f4bd6..2d6fead50 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -898,6 +898,7 @@ "idpDisplayName": "Име за показване за този доставчик на идентичност", "idpAutoProvisionUsers": "Автоматично потребителско създаване", "idpAutoProvisionUsersDescription": "Когато е активирано, потребителите ще бъдат автоматично създадени в системата при първо влизане с възможност за свързване на потребителите с роли и организации.", + "idpAutoProvisionConfigureAfterCreate": "Можете да конфигурирате настройките за автоматично предоставяне, след като дистрибуторът на самоличност бъде създаден.", "licenseBadge": "ЕЕ", "idpType": "Тип доставчик", "idpTypeDescription": "Изберете типа доставчик на идентичност, който искате да конфигурирате", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Карта на роля по подразбиране", "defaultMappingsRoleDescription": "Резултатът от този израз трябва да върне името на ролята, както е дефинирано в организацията, като стринг.", "defaultMappingsOrg": "Карта на организация по подразбиране", - "defaultMappingsOrgDescription": "Този израз трябва да върне ID на организацията или 'true', за да бъде разрешен достъпът на потребителя до организацията.", + "defaultMappingsOrgDescription": "При задаване, този израз трябва да върне идентификационния номер на организацията или true, за да се даде достъп на потребителя до тази организация. Ако не е зададено, дефинирането на роля е достатъчно: потребителят има право на достъп, стига валидно картографиране на роля да бъде разрешено за него в рамките на организацията.", "defaultMappingsSubmit": "Запазване на файловете по подразбиране", "orgPoliciesEdit": "Редактиране на Организационна Политика", "org": "Организация", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Открит международен домейн", "willbestoredas": "Ще бъде съхранено като:", - "roleMappingDescription": "Определете как се разпределят ролите на потребителите при вписване, когато е активирано автоматично предоставяне.", + "roleMappingDescription": "Определете как ролите се присвояват на потребителите, когато се вписват с този доставчик на самоличност.", "selectRole": "Избор на роля", "roleMappingExpression": "Израз", "selectRolePlaceholder": "Избор на роля", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Дестинацията беше актуализирана успешно", "httpDestCreatedSuccess": "Дестинацията беше създадена успешно", "httpDestUpdateFailed": "Неуспешно актуализиране на дестинацията", - "httpDestCreateFailed": "Неуспешно създаване на дестинацията" + "httpDestCreateFailed": "Неуспешно създаване на дестинацията", + "idpAddActionCreateNew": "Създайте нов доставчик на самоличност", + "idpAddActionImportFromOrg": "Импортиране от друга организация", + "idpImportDialogTitle": "Импортиране на доставчик на самоличност", + "idpImportDialogDescription": "Изберете доставчик на самоличност от организация, в която сте администратор. Той ще бъде свързан с тази организация.", + "idpImportSearchPlaceholder": "Търсене по име на организация или доставчик...", + "idpImportEmpty": "Няма намерени доставчици на самоличност.", + "idpImportedDescription": "Доставчикът на самоличност беше импортиран успешно.", + "idpDeleteGlobalQuestion": "Сигурни ли сте, че искате да изтриете този доставчик на самоличност завинаги?", + "idpDeleteGlobalDescription": "Това ще изтрие доставичка на самоличност завинаги от всички организации, с които е свързан.", + "idpUnassociateTitle": "Отвързване на доставчик на самоличност", + "idpUnassociateQuestion": "Сигурни ли сте, че искате да отвържете този доставчик на самоличност от тази организация?", + "idpUnassociateDescription": "Всички потребители, свързани с този доставчик на самоличност, ще бъдат премахнати от тази организация, но доставчика на самоличност ще продължи да съществува за други свързани организации.", + "idpUnassociateConfirm": "Потвърдете отвързване на доставчика на самоличност", + "idpUnassociateWarning": "Това не може да бъде отменено за тази организация.", + "idpUnassociatedDescription": "Доставчика на самоличност е успешно отвързан от тази организация", + "idpUnassociateMenu": "Отвързване", + "idpDeleteAllOrgsMenu": "Изтриване" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 66cee2a8b..e6e952e4b 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -898,6 +898,7 @@ "idpDisplayName": "Zobrazované jméno tohoto poskytovatele identity", "idpAutoProvisionUsers": "Automatická úprava uživatelů", "idpAutoProvisionUsersDescription": "Pokud je povoleno, uživatelé budou automaticky vytvářeni v systému při prvním přihlášení, s možností namapovat uživatele na role a organizace.", + "idpAutoProvisionConfigureAfterCreate": "Nastavení automatického poskytování lze nakonfigurovat, jakmile je vytvořen poskytovatel identity.", "licenseBadge": "PE", "idpType": "Typ poskytovatele", "idpTypeDescription": "Vyberte typ poskytovatele identity, který chcete nakonfigurovat", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Výchozí mapování rolí", "defaultMappingsRoleDescription": "Výsledek tohoto výrazu musí vrátit název role definovaný v organizaci jako řetězec.", "defaultMappingsOrg": "Výchozí mapování organizace", - "defaultMappingsOrgDescription": "Tento výraz musí vrátit org ID nebo pravdu, aby měl uživatel přístup k organizaci.", + "defaultMappingsOrgDescription": "Pokud je nastaven, musí tento výraz vracet ID organizace nebo pravda, aby k této organizaci měl uživatel přístup. Pokud není nastaveno, je dostačující definice mapování rolí: uživateli je umožněn přístup, pokud pro něj lze v rámci organizace vyřešit platné mapování rolí.", "defaultMappingsSubmit": "Uložit výchozí mapování", "orgPoliciesEdit": "Upravit zásady organizace", "org": "Organizace", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Zjištěna mezinárodní doména", "willbestoredas": "Bude uloženo jako:", - "roleMappingDescription": "Určete, jak jsou role přiřazeny uživatelům, když se přihlásí, když je povoleno automatické poskytnutí služby.", + "roleMappingDescription": "Určete, jak jsou role přiřazeny uživatelům, když se přihlásí s tímto poskytovatelem identity.", "selectRole": "Vyberte roli", "roleMappingExpression": "Výraz", "selectRolePlaceholder": "Vyberte roli", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Cíl byl úspěšně aktualizován", "httpDestCreatedSuccess": "Cíl byl úspěšně vytvořen", "httpDestUpdateFailed": "Nepodařilo se aktualizovat cíl", - "httpDestCreateFailed": "Nepodařilo se vytvořit cíl" + "httpDestCreateFailed": "Nepodařilo se vytvořit cíl", + "idpAddActionCreateNew": "Vytvořit nového poskytovatele identity", + "idpAddActionImportFromOrg": "Importovat z jiné organizace", + "idpImportDialogTitle": "Importovat poskytovatele identity", + "idpImportDialogDescription": "Vyberte poskytovatele identity z organizace, v níž jste administrátor. Tento poskytovatel bude propojen s touto organizací.", + "idpImportSearchPlaceholder": "Hledat podle názvu organizace nebo poskytovatele...", + "idpImportEmpty": "Nebyli nalezeni žádní poskytovatelé identity.", + "idpImportedDescription": "Poskytovatel identity byl úspěšně importován.", + "idpDeleteGlobalQuestion": "Opravdu chcete trvale smazat tohoto poskytovatele identity?", + "idpDeleteGlobalDescription": "Tímto bude poskytovatel identity trvale odstraněn ze všech organizací, se kterými je spojen.", + "idpUnassociateTitle": "Odpojit poskytovatele identity", + "idpUnassociateQuestion": "Opravdu chcete odpojit tohoto poskytovatele identity od této organizace?", + "idpUnassociateDescription": "Všichni uživatelé spojení s tímto poskytovatelem identity budou odstraněni z této organizace, ale poskytovatel identity zůstane nadále existovat pro ostatní přidružené organizace.", + "idpUnassociateConfirm": "Potvrdit odpojení poskytovatele identity", + "idpUnassociateWarning": "Toto nelze pro tuto organizaci vrátit.", + "idpUnassociatedDescription": "Poskytovatel identity byl úspěšně odpojen od této organizace", + "idpUnassociateMenu": "Odpojit", + "idpDeleteAllOrgsMenu": "Odstranit" } diff --git a/messages/de-DE.json b/messages/de-DE.json index 4ea6c9fe6..43e055c3b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -898,6 +898,7 @@ "idpDisplayName": "Ein Anzeigename für diesen Identitätsanbieter", "idpAutoProvisionUsers": "Automatische Benutzerbereitstellung", "idpAutoProvisionUsersDescription": "Wenn aktiviert, werden Benutzer beim ersten Login automatisch im System erstellt, mit der Möglichkeit, Benutzer Rollen und Organisationen zuzuordnen.", + "idpAutoProvisionConfigureAfterCreate": "Sie können die automatische Bereitstellung einstellen, sobald der Identitätsanbieter erstellt ist.", "licenseBadge": "EE", "idpType": "Anbietertyp", "idpTypeDescription": "Wählen Sie den Typ des Identitätsanbieters, den Sie konfigurieren möchten", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Standard-Rollenzuordnung", "defaultMappingsRoleDescription": "JMESPath zur Extraktion von Rolleninformationen aus dem ID-Token. Das Ergebnis dieses Ausdrucks muss den Rollennamen als String zurückgeben, wie er in der Organisation definiert ist.", "defaultMappingsOrg": "Standard-Organisationszuordnung", - "defaultMappingsOrgDescription": "JMESPath zur Extraktion von Organisationsinformationen aus dem ID-Token. Dieser Ausdruck muss die Organisations-ID oder true zurückgeben, damit der Benutzer Zugriff auf die Organisation erhält.", + "defaultMappingsOrgDescription": "Wenn diese Einstellung festgelegt ist, muss dieser Ausdruck die Organisations-ID oder wahr zurückgeben, damit der Benutzer diese Organisation betreten kann. Ist sie nicht festgelegt, reicht die Definition einer Rollenzuordnung aus: Der Benutzer darf eintreten, solange eine gültige Rollenzuordnung innerhalb der Organisation für ihn aufgelöst werden kann.", "defaultMappingsSubmit": "Standardzuordnungen speichern", "orgPoliciesEdit": "Organisationsrichtlinie bearbeiten", "org": "Organisation", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Internationale Domain erkannt", "willbestoredas": "Wird gespeichert als:", - "roleMappingDescription": "Legen Sie fest, wie den Benutzern Rollen zugewiesen werden, wenn sie sich anmelden, wenn Auto Provision aktiviert ist.", + "roleMappingDescription": "Bestimmen Sie, wie Rollen zugewiesen werden, wenn sich Benutzer mit diesem Identitätsanbieter anmelden.", "selectRole": "Wählen Sie eine Rolle", "roleMappingExpression": "Ausdruck", "selectRolePlaceholder": "Rolle auswählen", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Ziel erfolgreich aktualisiert", "httpDestCreatedSuccess": "Ziel erfolgreich erstellt", "httpDestUpdateFailed": "Fehler beim Aktualisieren des Ziels", - "httpDestCreateFailed": "Fehler beim Erstellen des Ziels" + "httpDestCreateFailed": "Fehler beim Erstellen des Ziels", + "idpAddActionCreateNew": "Neuen Identitätsanbieter erstellen", + "idpAddActionImportFromOrg": "Von einer anderen Organisation importieren", + "idpImportDialogTitle": "Identitätsanbieter importieren", + "idpImportDialogDescription": "Wählen Sie einen Identitätsanbieter aus einer Organisation, in der Sie Administrator sind. Er wird mit dieser Organisation verknüpft.", + "idpImportSearchPlaceholder": "Nach Organisation oder Anbieternamen suchen...", + "idpImportEmpty": "Keine Identitätsanbieter gefunden.", + "idpImportedDescription": "Identitätsanbieter erfolgreich importiert.", + "idpDeleteGlobalQuestion": "Sind Sie sicher, dass Sie diesen Identitätsanbieter dauerhaft löschen möchten?", + "idpDeleteGlobalDescription": "Dies wird den Identitätsanbieter dauerhaft von allen Organisationen löschen, mit denen er verbunden ist.", + "idpUnassociateTitle": "Verknüpfung mit Identitätsanbieter aufheben", + "idpUnassociateQuestion": "Sind Sie sicher, dass Sie die Verknüpfung dieses Identitätsanbieters mit dieser Organisation aufheben möchten?", + "idpUnassociateDescription": "Alle Benutzer, die mit diesem Identitätsanbieter verbunden sind, werden aus dieser Organisation entfernt, aber der Identitätsanbieter bleibt für andere verbundene Organisationen weiterhin bestehen.", + "idpUnassociateConfirm": "Verknüpfung des Identitätsanbieters aufheben bestätigen", + "idpUnassociateWarning": "Dies kann für diese Organisation nicht rückgängig gemacht werden.", + "idpUnassociatedDescription": "Identitätsanbieter erfolgreich von dieser Organisation gelöst", + "idpUnassociateMenu": "Verknüpfung aufheben", + "idpDeleteAllOrgsMenu": "Löschen" } diff --git a/messages/en-US.json b/messages/en-US.json index ee895be9f..91b02362c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -175,7 +175,7 @@ "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "proxyResourcesBannerTitle": "Web-based Public Access", - "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", @@ -392,7 +392,7 @@ "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", "userAbount": "About User Management", - "userAbountDescription": "This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.", + "userAbountDescription": "This table displays all base user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their base user object. They will remain in the system. To completely remove a user from the system, you must delete their base user object using the delete action in this table.", "userServer": "Server Users", "userSearch": "Search server users...", "userErrorDelete": "Error deleting user", @@ -535,7 +535,7 @@ "userSettings": "User Information", "userSettingsDescription": "Enter the details for the new user", "inviteEmailSent": "Send invite email to user", - "inviteValid": "Valid For", + "inviteValid": "Invite Valid For (days)", "selectDuration": "Select duration", "selectResource": "Select Resource", "filterByResource": "Filter By Resource", @@ -910,6 +910,7 @@ "idpDisplayName": "A display name for this identity provider", "idpAutoProvisionUsers": "Auto Provision Users", "idpAutoProvisionUsersDescription": "When enabled, users will be automatically created in the system upon first login with the ability to map users to roles and organizations.", + "idpAutoProvisionConfigureAfterCreate": "You can configure auto provision settings once the identity provider is created.", "licenseBadge": "EE", "idpType": "Provider Type", "idpTypeDescription": "Select the type of identity provider you want to configure", @@ -961,7 +962,7 @@ "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", - "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", + "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining a role mapping is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", @@ -1359,7 +1360,7 @@ "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "alertingTitle": "Alerting", - "alertingDescription": "Define sources, triggers, and actions for notifications.", + "alertingDescription": "Define sources, triggers, and actions for notifications", "alertingRules": "Alert rules", "alertingSearchRules": "Search rules…", "alertingAddRule": "Create Rule", @@ -1383,6 +1384,18 @@ "alertingPickSites": "Sites", "alertingPickHealthChecks": "Health checks", "alertingPickResources": "Resources", + "alertingAllSites": "All Sites", + "alertingAllSitesDescription": "Alert fires for any site", + "alertingSpecificSites": "Specific Sites", + "alertingSpecificSitesDescription": "Choose specific sites to watch", + "alertingAllHealthChecks": "All Health Checks", + "alertingAllHealthChecksDescription": "Alert fires for any health check", + "alertingSpecificHealthChecks": "Specific Health Checks", + "alertingSpecificHealthChecksDescription": "Choose specific health checks to watch", + "alertingAllResources": "All Resources", + "alertingAllResourcesDescription": "Alert fires for any resource", + "alertingSpecificResources": "Specific resources", + "alertingSpecificResourcesDescription": "Choose specific resources to watch", "alertingSelectResources": "Select resources…", "alertingResourcesSelected": "{count} resources selected", "alertingResourcesEmpty": "No resources with targets in the first 10 results.", @@ -1390,12 +1403,27 @@ "alertingTrigger": "When to alert", "alertingTriggerSiteOnline": "Site online", "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Site status changes", "alertingTriggerHcHealthy": "Health check healthy", "alertingTriggerHcUnhealthy": "Health check unhealthy", + "alertingTriggerHcToggle": "Health check status changes", + "alertingTriggerResourceHealthy": "Resource healthy", + "alertingTriggerResourceUnhealthy": "Resource unhealthy", + "alertingSearchHealthChecks": "Search health checks…", + "alertingHealthChecksEmpty": "No health checks available.", + "alertingTriggerResourceToggle": "Resource status changes", + "alertingSourceResource": "Resource", "alertingSectionActions": "Actions", "alertingAddAction": "Add action", "alertingActionNotify": "Email", + "alertingActionNotifyDescription": "Send email notifications to users or roles", "alertingActionWebhook": "Webhook", + "alertingActionWebhookDescription": "Send an HTTP request to a custom endpoint", + "alertingExternalIntegration": "External Integration", + "alertingExternalPagerDutyDescription": "Send alerts to PagerDuty for incident management", + "alertingExternalOpsgenieDescription": "Route alerts to Opsgenie for on-call management", + "alertingExternalServiceNowDescription": "Create ServiceNow incidents from alert events", + "alertingExternalIncidentIoDescription": "Trigger Incident.io workflows from alert events", "alertingActionType": "Action type", "alertingNotifyUsers": "Users", "alertingNotifyRoles": "Roles", @@ -1418,12 +1446,15 @@ "alertingRolesSelected": "{count} roles selected", "alertingSummarySites": "Sites ({count})", "alertingSummaryHealthChecks": "Health checks ({count})", + "alertingSummaryResources": "Resources ({count})", "alertingErrorNameRequired": "Enter a name", "alertingErrorActionsMin": "Add at least one action", "alertingErrorPickSites": "Select at least one site", "alertingErrorPickHealthChecks": "Select at least one health check", + "alertingErrorPickResources": "Select at least one resource", "alertingErrorTriggerSite": "Choose a site trigger", "alertingErrorTriggerHealth": "Choose a health check trigger", + "alertingErrorTriggerResource": "Choose a resource trigger", "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", "alertingConfigureSource": "Configure Source", "alertingConfigureTrigger": "Configure Trigger", @@ -2160,7 +2191,7 @@ }, "internationaldomaindetected": "International Domain Detected", "willbestoredas": "Will be stored as:", - "roleMappingDescription": "Determine how roles are assigned to users when they sign in when Auto Provision is enabled.", + "roleMappingDescription": "Determine how roles are assigned to users when they sign in with this identity provider.", "selectRole": "Select a Role", "roleMappingExpression": "Expression", "selectRolePlaceholder": "Choose a role", @@ -2549,7 +2580,7 @@ "action": "Action", "actor": "Actor", "timestamp": "Timestamp", - "accessLogs": "Access Logs", + "accessLogs": "Authentication Logs", "exportCsv": "Export CSV", "exportError": "Unknown error when exporting CSV", "exportCsvTooltip": "Within Time Range", @@ -2570,25 +2601,25 @@ "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Reason", - "requestLogs": "Request Logs", + "requestLogs": "HTTPS Request Logs", "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", - "actionLogs": "Action Logs", - "sidebarLogsRequest": "Request Logs", - "sidebarLogsAccess": "Access Logs", - "sidebarLogsAction": "Action Logs", + "actionLogs": "Admin Action Logs", + "sidebarLogsRequest": "HTTPS Request Logs", + "sidebarLogsAccess": "Authentication Logs", + "sidebarLogsAction": "Admin Action Logs", "logRetention": "Log Retention", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", - "requestLogsDescription": "View detailed request logs for 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", - "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestLabel": "HTTPS Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", - "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessLabel": "Authentication Log Retention", "logRetentionAccessDescription": "How long to retain access logs", - "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionLabel": "Admin Action Log Retention", "logRetentionActionDescription": "How long to retain action logs", - "logRetentionConnectionLabel": "Connection Log Retention", + "logRetentionConnectionLabel": "Network Log Retention", "logRetentionConnectionDescription": "How long to retain connection logs", "logRetentionDisabled": "Disabled", "logRetention3Days": "3 days", @@ -2600,10 +2631,10 @@ "logRetentionEndOfFollowingYear": "End of following year", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", - "connectionLogs": "Connection Logs", - "connectionLogsDescription": "View connection logs for tunnels in this organization", - "sidebarLogsConnection": "Connection Logs", - "sidebarLogsStreaming": "Streaming", + "connectionLogs": "Network Logs", + "connectionLogsDescription": "View network session logs handled by sites in this organization", + "sidebarLogsConnection": "Network Logs", + "sidebarLogsStreaming": "Event Streaming", "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", @@ -3035,13 +3066,13 @@ "httpDestFormatSingleDescription": "Sends a separate HTTP POST for each individual event. Use only for endpoints that cannot handle batches.", "httpDestLogTypesTitle": "Log Types", "httpDestLogTypesDescription": "Choose which log types are forwarded to this destination. Only enabled log types will be streamed.", - "httpDestAccessLogsTitle": "Access Logs", + "httpDestAccessLogsTitle": "Authentication Logs", "httpDestAccessLogsDescription": "Resource access attempts, including authenticated and denied requests.", - "httpDestActionLogsTitle": "Action Logs", + "httpDestActionLogsTitle": "Admin Action Logs", "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", - "httpDestConnectionLogsTitle": "Connection Logs", + "httpDestConnectionLogsTitle": "Network Logs", "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", - "httpDestRequestLogsTitle": "Request Logs", + "httpDestRequestLogsTitle": "HTTPS Request Logs", "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", "httpDestSaveChanges": "Save Changes", "httpDestCreateDestination": "Create Destination", @@ -3060,5 +3091,23 @@ "healthCheckTabConnection": "Connection", "healthCheckTabAdvanced": "Advanced", "healthCheckStrategyNotAvailable": "This strategy is not available. Please contact sales to enable this feature.", - "uptime30d": "Uptime (30d)" + "uptime30d": "Uptime (30d)", + "idpAddActionCreateNew": "Create new identity provider", + "idpAddActionImportFromOrg": "Import from another organization", + "idpImportDialogTitle": "Import Identity Provider", + "idpImportDialogDescription": "Choose an identity provider from an organization where you are an admin. It will be linked to this organization.", + "idpImportSearchPlaceholder": "Search by organization or provider name...", + "idpImportEmpty": "No identity providers found.", + "idpImportedDescription": "Identity provider imported successfully.", + "idpDeleteGlobalQuestion": "Are you sure you want to permanently delete this identity provider?", + "idpDeleteGlobalDescription": "This will permanently delete the identity provider from all organizations it is associated with.", + "idpUnassociateTitle": "Unassociate Identity Provider", + "idpUnassociateQuestion": "Are you sure you want to unassociate this identity provider from this organization?", + "idpUnassociateDescription": "All users associated with this identity provider will be removed from this organization, but the identity provider will still continue to exist for other associated organizations.", + "idpUnassociateConfirm": "Confirm Unassociate Identity Provider", + "idpUnassociateWarning": "This cannot be undone for this organization.", + "idpUnassociatedDescription": "Identity provider unassociated from this organization successfully", + "idpUnassociateMenu": "Unassociate", + "idpDeleteAllOrgsMenu": "Delete", + "publicIpEndpoint": "Endpoint" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 0fa9201c8..b370ee7dc 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -898,6 +898,7 @@ "idpDisplayName": "Un nombre mostrado para este proveedor de identidad", "idpAutoProvisionUsers": "Auto-Provisión de Usuarios", "idpAutoProvisionUsersDescription": "Cuando está habilitado, los usuarios serán creados automáticamente en el sistema al iniciar sesión con la capacidad de asignar a los usuarios a roles y organizaciones.", + "idpAutoProvisionConfigureAfterCreate": "Puede configurar las configuraciones de provisión automática una vez que se haya creado el proveedor de identidad.", "licenseBadge": "EE", "idpType": "Tipo de proveedor", "idpTypeDescription": "Seleccione el tipo de proveedor de identidad que desea configurar", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Mapeo de Rol por defecto", "defaultMappingsRoleDescription": "El resultado de esta expresión debe devolver el nombre del rol tal y como se define en la organización como una cadena.", "defaultMappingsOrg": "Mapeo de organización por defecto", - "defaultMappingsOrgDescription": "Esta expresión debe devolver el ID de org o verdadero para que el usuario pueda acceder a la organización.", + "defaultMappingsOrgDescription": "Cuando se establece, esta expresión debe devolver el ID de la organización o verdadero para que el usuario acceda a esa organización. Cuando no se establece, definir un mapeo de roles es suficiente: se permite la entrada del usuario siempre que se pueda resolver un mapeo de roles válido para él dentro de la organización.", "defaultMappingsSubmit": "Guardar asignaciones por defecto", "orgPoliciesEdit": "Editar Política de Organización", "org": "Organización", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Dominio Internacional detectado", "willbestoredas": "Se almacenará como:", - "roleMappingDescription": "Determinar cómo se asignan los roles a los usuarios cuando se registran cuando está habilitada la provisión automática.", + "roleMappingDescription": "Determine cómo se asignan los roles a los usuarios cuando inician sesión con este proveedor de identidad.", "selectRole": "Seleccione un rol", "roleMappingExpression": "Expresión", "selectRolePlaceholder": "Elija un rol", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Destino actualizado correctamente", "httpDestCreatedSuccess": "Destino creado correctamente", "httpDestUpdateFailed": "Error al actualizar destino", - "httpDestCreateFailed": "Error al crear el destino" + "httpDestCreateFailed": "Error al crear el destino", + "idpAddActionCreateNew": "Crear nuevo proveedor de identidad", + "idpAddActionImportFromOrg": "Importar de otra organización", + "idpImportDialogTitle": "Importar Proveedor de Identidad", + "idpImportDialogDescription": "Elija un proveedor de identidad de una organización donde usted sea administrador. Se vinculará a esta organización.", + "idpImportSearchPlaceholder": "Buscar por nombre de organización o proveedor...", + "idpImportEmpty": "No se encontraron proveedores de identidad.", + "idpImportedDescription": "Proveedor de identidad importado con éxito.", + "idpDeleteGlobalQuestion": "¿Está seguro de que desea eliminar permanentemente este proveedor de identidad?", + "idpDeleteGlobalDescription": "Esto eliminará permanentemente el proveedor de identidad de todas las organizaciones con las que está asociado.", + "idpUnassociateTitle": "Desasociar Proveedor de Identidad", + "idpUnassociateQuestion": "¿Está seguro de que desea desasociar este proveedor de identidad de esta organización?", + "idpUnassociateDescription": "Todos los usuarios asociados con este proveedor de identidad serán eliminados de esta organización, pero el proveedor de identidad continuará existiendo para otras organizaciones asociadas.", + "idpUnassociateConfirm": "Confirme Desasociar Proveedor de Identidad", + "idpUnassociateWarning": "Esto no se puede deshacer para esta organización.", + "idpUnassociatedDescription": "Proveedor de identidad desasociado de esta organización con éxito", + "idpUnassociateMenu": "Desasociar", + "idpDeleteAllOrgsMenu": "Eliminar" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 419701b5f..98b769366 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -898,6 +898,7 @@ "idpDisplayName": "Un nom d'affichage pour ce fournisseur d'identité", "idpAutoProvisionUsers": "Approvisionnement automatique des utilisateurs", "idpAutoProvisionUsersDescription": "Lorsque cette option est activée, les utilisateurs seront automatiquement créés dans le système lors de leur première connexion avec la possibilité de mapper les utilisateurs aux rôles et aux organisations.", + "idpAutoProvisionConfigureAfterCreate": "Vous pouvez configurer les paramètres de provisionnement automatique une fois le fournisseur d'identités créé.", "licenseBadge": "EE", "idpType": "Type de fournisseur", "idpTypeDescription": "Sélectionnez le type de fournisseur d'identité que vous souhaitez configurer", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Mappage de rôle par défaut", "defaultMappingsRoleDescription": "JMESPath pour extraire les informations de rôle du jeton ID. Le résultat de cette expression doit renvoyer le nom du rôle tel que défini dans l'organisation sous forme de chaîne.", "defaultMappingsOrg": "Mappage d'organisation par défaut", - "defaultMappingsOrgDescription": "JMESPath pour extraire les informations d'organisation du jeton ID. Cette expression doit renvoyer l'ID de l'organisation ou true pour que l'utilisateur soit autorisé à accéder à l'organisation.", + "defaultMappingsOrgDescription": "Lorsque défini, cette expression doit renvoyer l'identifiant de l'organisation ou vrai pour que l'utilisateur accède à cette organisation. Lorsqu'indéfini, définir un mappage de rôle est suffisant : l'utilisateur est autorisé tant qu'un mappage de rôle valide peut être résolu pour lui au sein de l'organisation.", "defaultMappingsSubmit": "Enregistrer les mappages par défaut", "orgPoliciesEdit": "Modifier la politique d'organisation", "org": "Organisation", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Domaine international détecté", "willbestoredas": "Sera stocké comme :", - "roleMappingDescription": "Détermine comment les rôles sont assignés aux utilisateurs lorsqu'ils se connectent lorsque la fourniture automatique est activée.", + "roleMappingDescription": "Déterminez comment les rôles sont attribués aux utilisateurs lorsqu'ils se connectent avec ce fournisseur d'identité.", "selectRole": "Sélectionnez un rôle", "roleMappingExpression": "Expression", "selectRolePlaceholder": "Choisir un rôle", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Destination mise à jour avec succès", "httpDestCreatedSuccess": "Destination créée avec succès", "httpDestUpdateFailed": "Impossible de mettre à jour la destination", - "httpDestCreateFailed": "Impossible de créer la destination" + "httpDestCreateFailed": "Impossible de créer la destination", + "idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité", + "idpAddActionImportFromOrg": "Importer d'une autre organisation", + "idpImportDialogTitle": "Importer le fournisseur d'identité", + "idpImportDialogDescription": "Choisissez un fournisseur d'identités d'une organisation où vous êtes administrateur. Il sera lié à cette organisation.", + "idpImportSearchPlaceholder": "Recherche par nom d'organisation ou de fournisseur...", + "idpImportEmpty": "Aucun fournisseur d'identités trouvé.", + "idpImportedDescription": "Fournisseur d'identités importé avec succès.", + "idpDeleteGlobalQuestion": "Êtes-vous sûr de vouloir supprimer définitivement ce fournisseur d'identités?", + "idpDeleteGlobalDescription": "Cela supprimera définitivement le fournisseur d'identités de toutes les organisations auxquelles il est associé.", + "idpUnassociateTitle": "Dissocier le fournisseur d'identité", + "idpUnassociateQuestion": "Êtes-vous sûr de vouloir dissocier ce fournisseur d'identités de cette organisation?", + "idpUnassociateDescription": "Tous les utilisateurs associés à ce fournisseur d'identités seront retirés de cette organisation, mais le fournisseur d'identités continuera d'exister pour d'autres organisations associées.", + "idpUnassociateConfirm": "Confirmer la dissociation du fournisseur d'identités", + "idpUnassociateWarning": "Cela ne peut pas être annulé pour cette organisation.", + "idpUnassociatedDescription": "Fournisseur d'identités dissocié de cette organisation avec succès", + "idpUnassociateMenu": "Dissocier", + "idpDeleteAllOrgsMenu": "Supprimer" } diff --git a/messages/it-IT.json b/messages/it-IT.json index e761ea55f..babe33b59 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -898,6 +898,7 @@ "idpDisplayName": "Un nome visualizzato per questo provider di identità", "idpAutoProvisionUsers": "Provisioning Automatico Utenti", "idpAutoProvisionUsersDescription": "Quando abilitato, gli utenti verranno creati automaticamente nel sistema al primo accesso con la possibilità di mappare gli utenti a ruoli e organizzazioni.", + "idpAutoProvisionConfigureAfterCreate": "Puoi configurare le impostazioni di auto fornitura una volta creato il provider di identità.", "licenseBadge": "EE", "idpType": "Tipo di Provider", "idpTypeDescription": "Seleziona il tipo di provider di identità che desideri configurare", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Mappatura Ruolo Predefinito", "defaultMappingsRoleDescription": "JMESPath per estrarre informazioni sul ruolo dal token ID. Il risultato di questa espressione deve restituire il nome del ruolo come definito nell'organizzazione come stringa.", "defaultMappingsOrg": "Mappatura Organizzazione Predefinita", - "defaultMappingsOrgDescription": "JMESPath per estrarre informazioni sull'organizzazione dal token ID. Questa espressione deve restituire l'ID dell'organizzazione o true affinché l'utente possa accedere all'organizzazione.", + "defaultMappingsOrgDescription": "Quando impostata, questa espressione deve restituire l'ID dell'organizzazione o true affinché l'utente possa accedere a quell'organizzazione. Quando non impostata, è sufficiente definire una mappatura di ruoli: l'utente è autorizzato se esiste una mappatura di ruolo valida per loro all'interno dell'organizzazione.", "defaultMappingsSubmit": "Salva Mappature Predefinite", "orgPoliciesEdit": "Modifica Politica Organizzazione", "org": "Organizzazione", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Dominio Internazionale Rilevato", "willbestoredas": "Verrà conservato come:", - "roleMappingDescription": "Determinare come i ruoli sono assegnati agli utenti quando accedono quando è abilitata la fornitura automatica.", + "roleMappingDescription": "Determina come i ruoli vengono assegnati agli utenti quando si accede con questo provider di identità.", "selectRole": "Seleziona un ruolo", "roleMappingExpression": "Espressione", "selectRolePlaceholder": "Scegli un ruolo", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Destinazione aggiornata con successo", "httpDestCreatedSuccess": "Destinazione creata con successo", "httpDestUpdateFailed": "Impossibile aggiornare la destinazione", - "httpDestCreateFailed": "Impossibile creare la destinazione" + "httpDestCreateFailed": "Impossibile creare la destinazione", + "idpAddActionCreateNew": "Crea nuovo provider di identità", + "idpAddActionImportFromOrg": "Importa da un'altra organizzazione", + "idpImportDialogTitle": "Importa Provider di Identità", + "idpImportDialogDescription": "Scegli un provider di identità da un'organizzazione di cui sei amministratore. Verrà collegato a questa organizzazione.", + "idpImportSearchPlaceholder": "Cerca per nome organizzazione o provider...", + "idpImportEmpty": "Nessun provider di identità trovato.", + "idpImportedDescription": "Provider di identità importato con successo.", + "idpDeleteGlobalQuestion": "Sei sicuro di voler eliminare definitivamente questo provider di identità?", + "idpDeleteGlobalDescription": "Questo eliminerà definitivamente il provider di identità da tutte le organizzazioni con cui è associato.", + "idpUnassociateTitle": "Disassociare Provider di Identità", + "idpUnassociateQuestion": "Sei sicuro di voler disassociare questo provider di identità da questa organizzazione?", + "idpUnassociateDescription": "Tutti gli utenti associati a questo provider di identità verranno rimossi da questa organizzazione, ma il provider di identità continuerà ad esistere per altre organizzazioni associate.", + "idpUnassociateConfirm": "Conferma Disassociazione Provider di Identità", + "idpUnassociateWarning": "Questo non può essere annullato per questa organizzazione.", + "idpUnassociatedDescription": "Provider di identità disassociato con successo da questa organizzazione", + "idpUnassociateMenu": "Disassocia", + "idpDeleteAllOrgsMenu": "Elimina" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index f394fa2d6..9e55b0d32 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -898,6 +898,7 @@ "idpDisplayName": "이 신원 공급자를 위한 표시 이름", "idpAutoProvisionUsers": "사용자 자동 프로비저닝", "idpAutoProvisionUsersDescription": "활성화되면 사용자가 첫 로그인 시 시스템에 자동으로 생성되며, 사용자와 역할 및 조직을 매핑할 수 있습니다.", + "idpAutoProvisionConfigureAfterCreate": "아이덴티티 공급자가 생성되면 자동 프로비저닝 설정을 구성할 수 있습니다.", "licenseBadge": "EE", "idpType": "제공자 유형", "idpTypeDescription": "구성할 ID 공급자의 유형을 선택하십시오.", @@ -949,7 +950,7 @@ "defaultMappingsRole": "기본 역할 매핑", "defaultMappingsRoleDescription": "이 표현식의 결과는 조직에서 정의된 역할 이름을 문자열로 반환해야 합니다.", "defaultMappingsOrg": "기본 조직 매핑", - "defaultMappingsOrgDescription": "이 표현식은 사용자가 조직에 접근할 수 있도록 조직 ID 또는 true를 반환해야 합니다.", + "defaultMappingsOrgDescription": "이 표현식은 사용자가 조직에 접근할 수 있도록 조직 ID 또는 true를 반환해야 합니다. 설정되지 않으면, 역할 매핑 정의가 충분합니다: 사용자는 유효한 역할 매핑이 해석되는 한 조직에 허용됩니다.", "defaultMappingsSubmit": "기본 매핑 저장", "orgPoliciesEdit": "조직 정책 편집", "org": "조직", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "국제 도메인 감지됨", "willbestoredas": "다음으로 저장됩니다:", - "roleMappingDescription": "자동 프로비저닝이 활성화되면 사용자가 로그인할 때 역할이 할당되는 방법을 결정합니다.", + "roleMappingDescription": "사용자가 이 아이덴티티 공급자로 로그인할 때 역할이 할당되는 방법을 결정합니다.", "selectRole": "역할 선택", "roleMappingExpression": "표현식", "selectRolePlaceholder": "역할 선택", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "대상지가 성공적으로 업데이트되었습니다", "httpDestCreatedSuccess": "대상지가 성공적으로 생성되었습니다", "httpDestUpdateFailed": "대상지를 업데이트하는 데 실패했습니다", - "httpDestCreateFailed": "대상지를 생성하는 데 실패했습니다" + "httpDestCreateFailed": "대상지를 생성하는 데 실패했습니다", + "idpAddActionCreateNew": "새로운 아이덴티티 공급자 생성", + "idpAddActionImportFromOrg": "다른 조직에서 가져오기", + "idpImportDialogTitle": "아이덴티티 공급자 가져오기", + "idpImportDialogDescription": "관리자인 조직에서 아이덴티티 공급자를 선택하십시오. 이는 이 조직에 연결됩니다.", + "idpImportSearchPlaceholder": "조직 또는 공급자 이름으로 검색...", + "idpImportEmpty": "아이덴티티 공급자를 찾을 수 없습니다.", + "idpImportedDescription": "아이덴티티 공급자가 성공적으로 가져왔습니다.", + "idpDeleteGlobalQuestion": "정말로 이 아이덴티티 공급자를 영구적으로 삭제하시겠습니까?", + "idpDeleteGlobalDescription": "이것은 연관된 모든 조직에서 아이덴티티 공급자를 영구적으로 삭제합니다.", + "idpUnassociateTitle": "아이덴티티 공급자의 연관 해제", + "idpUnassociateQuestion": "정말로 이 조직에서 이 아이덴티티 공급자의 연관을 해제하시겠습니까?", + "idpUnassociateDescription": "이 아이덴티티 공급자와 연관된 모든 사용자는 이 조직에서 제거될 것이지만, 아이덴티티 공급자는 다른 연관된 조직에 계속해서 존재할 것입니다.", + "idpUnassociateConfirm": "아이덴티티 공급자 연관 해제 확인", + "idpUnassociateWarning": "이 조직에서 이것은 되돌릴 수 없습니다.", + "idpUnassociatedDescription": "아이덴티티 공급자가 이 조직에서 성공적으로 연관 해제되었습니다", + "idpUnassociateMenu": "연관 해제", + "idpDeleteAllOrgsMenu": "삭제" } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index d8bd93680..913d7ca94 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -898,6 +898,7 @@ "idpDisplayName": "Et visningsnavn for denne identitetsleverandøren", "idpAutoProvisionUsers": "Automatisk brukerklargjøring", "idpAutoProvisionUsersDescription": "Når aktivert, opprettes brukere automatisk i systemet ved første innlogging, med mulighet til å tilordne brukere til roller og organisasjoner.", + "idpAutoProvisionConfigureAfterCreate": "Du kan konfigurere autoprovisjonsinnstillingene når identitetsleverandøren er opprettet.", "licenseBadge": "EE", "idpType": "Leverandørtype", "idpTypeDescription": "Velg typen identitetsleverandør du ønsker å konfigurere", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Standard rolletilordning", "defaultMappingsRoleDescription": "Resultatet av dette uttrykket må returnere rollenavnet slik det er definert i organisasjonen som en streng.", "defaultMappingsOrg": "Standard organisasjonstilordning", - "defaultMappingsOrgDescription": "Dette uttrykket må returnere organisasjons-ID-en eller «true» for å gi brukeren tilgang til organisasjonen.", + "defaultMappingsOrgDescription": "Når denne er satt, må uttrykket returnere organisasjons-IDen eller «true» for at brukeren skal få tilgang til den organisasjonen. Når den ikke er satt, er det nok å definere en rolletilordning: brukeren gis tilgang så lenge en gyldig rolletilknytting kan løses for dem i organisasjonen.", "defaultMappingsSubmit": "Lagre standard tilordninger", "orgPoliciesEdit": "Rediger Organisasjonspolicy", "org": "Organisasjon", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Internasjonalt domene oppdaget", "willbestoredas": "Vil bli lagret som:", - "roleMappingDescription": "Bestem hvordan roller tilordnes brukere når innloggingen er aktivert når autog-rapportering er aktivert.", + "roleMappingDescription": "Bestem hvordan roller tildeles brukere når de logger inn med denne identitetsleverandøren.", "selectRole": "Velg en rolle", "roleMappingExpression": "Uttrykk", "selectRolePlaceholder": "Velg en rolle", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Målet er oppdatert", "httpDestCreatedSuccess": "Målet er opprettet", "httpDestUpdateFailed": "Kunne ikke oppdatere destinasjon", - "httpDestCreateFailed": "Kan ikke opprette mål" + "httpDestCreateFailed": "Kan ikke opprette mål", + "idpAddActionCreateNew": "Opprett ny identitetsleverandør", + "idpAddActionImportFromOrg": "Importer fra en annen organisasjon", + "idpImportDialogTitle": "Importer identitetsleverandør", + "idpImportDialogDescription": "Velg en identitetsleverandør fra en organisasjon der du er admin. Den vil bli knyttet til denne organisasjonen.", + "idpImportSearchPlaceholder": "Søk etter organisasjons- eller leverandørnavn...", + "idpImportEmpty": "Ingen identitetsleverandører funnet.", + "idpImportedDescription": "Identitetsleverandøren ble importert vellykket.", + "idpDeleteGlobalQuestion": "Er du sikker på at du vil slette denne identitetsleverandøren permanent?", + "idpDeleteGlobalDescription": "Dette vil slette identitetsleverandøren permanent fra alle organisasjoner den er tilknyttet.", + "idpUnassociateTitle": "Frakoble identitetsleverandør", + "idpUnassociateQuestion": "Er du sikker på at du vil frakoble denne identitetsleverandøren fra denne organisasjonen?", + "idpUnassociateDescription": "Alle brukere knyttet til denne identitetsleverandøren vil bli fjernet fra denne organisasjonen, men identitetsleverandøren vil fortsatt eksistere for andre tilknyttede organisasjoner.", + "idpUnassociateConfirm": "Bekreft frakobling av identitetsleverandør", + "idpUnassociateWarning": "Dette kan ikke angres for denne organisasjonen.", + "idpUnassociatedDescription": "Identitetsleverandør er vellykket frakoblet fra denne organisasjonen", + "idpUnassociateMenu": "Frakoble", + "idpDeleteAllOrgsMenu": "Slett" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index fce7c836f..f3803d445 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -898,6 +898,7 @@ "idpDisplayName": "Een weergavenaam voor deze identiteitsprovider", "idpAutoProvisionUsers": "Auto Provisie Gebruikers", "idpAutoProvisionUsersDescription": "Wanneer ingeschakeld, worden gebruikers automatisch in het systeem aangemaakt wanneer ze de eerste keer inloggen met de mogelijkheid om gebruikers toe te wijzen aan rollen en organisaties.", + "idpAutoProvisionConfigureAfterCreate": "U kunt automatische voorzieningsinstellingen configureren zodra de identiteitsprovider is aangemaakt.", "licenseBadge": "EE", "idpType": "Type provider", "idpTypeDescription": "Selecteer het type identiteitsprovider dat u wilt configureren", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Standaard Rol Toewijzing", "defaultMappingsRoleDescription": "Het resultaat van deze uitdrukking moet de rolnaam zoals gedefinieerd in de organisatie als tekenreeks teruggeven.", "defaultMappingsOrg": "Standaard organisatie mapping", - "defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.", + "defaultMappingsOrgDescription": "Wanneer ingesteld, moet deze expressie de organisatie-ID of waar retourneren voor de gebruiker om toegang te krijgen tot die organisatie. Als het niet is ingesteld, is het definiëren van een roltoewijzing voldoende: de gebruiker is toegestaan zolang een geldige roltoewijzing voor hen binnen de organisatie kan worden opgelost.", "defaultMappingsSubmit": "Standaard toewijzingen opslaan", "orgPoliciesEdit": "Organisatie beleid bewerken", "org": "Organisatie", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Internationaal Domein Gedetecteerd", "willbestoredas": "Zal worden opgeslagen als:", - "roleMappingDescription": "Bepaal hoe rollen worden toegewezen aan gebruikers wanneer ze inloggen wanneer Auto Provision is ingeschakeld.", + "roleMappingDescription": "Bepaal hoe rollen aan gebruikers worden toegewezen wanneer ze zich aanmelden met deze identiteitsprovider.", "selectRole": "Selecteer een rol", "roleMappingExpression": "Expressie", "selectRolePlaceholder": "Kies een rol", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Bestemming succesvol bijgewerkt", "httpDestCreatedSuccess": "Bestemming succesvol aangemaakt", "httpDestUpdateFailed": "Bijwerken bestemming mislukt", - "httpDestCreateFailed": "Aanmaken bestemming mislukt" + "httpDestCreateFailed": "Aanmaken bestemming mislukt", + "idpAddActionCreateNew": "Nieuwe identiteitsprovider aanmaken", + "idpAddActionImportFromOrg": "Importeer vanuit een andere organisatie", + "idpImportDialogTitle": "Importeer Identiteitsprovider", + "idpImportDialogDescription": "Kies een identiteitsprovider van een organisatie waar u beheerder bent. Het wordt gekoppeld aan deze organisatie.", + "idpImportSearchPlaceholder": "Zoek op organisatie- of providernamen...", + "idpImportEmpty": "Geen identiteitsproviders gevonden.", + "idpImportedDescription": "Identiteitsprovider succesvol geïmporteerd.", + "idpDeleteGlobalQuestion": "Weet u zeker dat u deze identiteitsprovider permanent wilt verwijderen?", + "idpDeleteGlobalDescription": "Hiermee wordt de identiteitsprovider permanent verwijderd uit alle organisaties waarmee het is geassocieerd.", + "idpUnassociateTitle": "Koppel Identiteitsprovider los", + "idpUnassociateQuestion": "Weet u zeker dat u deze identiteitsprovider van deze organisatie wilt loskoppelen?", + "idpUnassociateDescription": "Alle gebruikers die aan deze identiteitsprovider zijn gekoppeld, worden uit deze organisatie verwijderd, maar de identiteitsprovider blijft bestaan voor andere gerelateerde organisaties.", + "idpUnassociateConfirm": "Bevestig ontkoppelen identiteitsprovider", + "idpUnassociateWarning": "Dit kan niet ongedaan worden gemaakt voor deze organisatie.", + "idpUnassociatedDescription": "Identiteitsprovider succesvol losgekoppeld van deze organisatie", + "idpUnassociateMenu": "Ontkoppelen", + "idpDeleteAllOrgsMenu": "Verwijderen" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 41b10b7fb..2e55ad2a8 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -898,6 +898,7 @@ "idpDisplayName": "Nazwa wyświetlana dla tego dostawcy tożsamości", "idpAutoProvisionUsers": "Automatyczne tworzenie użytkowników", "idpAutoProvisionUsersDescription": "Gdy włączone, użytkownicy będą automatycznie tworzeni w systemie przy pierwszym logowaniu z możliwością mapowania użytkowników do ról i organizacji.", + "idpAutoProvisionConfigureAfterCreate": "Możesz skonfigurować automatyczne ustawienia provision, gdy dostawca tożsamości zostanie utworzony.", "licenseBadge": "EE", "idpType": "Typ dostawcy", "idpTypeDescription": "Wybierz typ dostawcy tożsamości, który chcesz skonfigurować", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Domyślne mapowanie roli", "defaultMappingsRoleDescription": "JMESPath do wydobycia informacji o roli z tokena ID. Wynik tego wyrażenia musi zwrócić nazwę roli zdefiniowaną w organizacji jako ciąg znaków.", "defaultMappingsOrg": "Domyślne mapowanie organizacji", - "defaultMappingsOrgDescription": "JMESPath do wydobycia informacji o organizacji z tokena ID. To wyrażenie musi zwrócić ID organizacji lub true, aby użytkownik mógł uzyskać dostęp do organizacji.", + "defaultMappingsOrgDescription": "Gdy jest ustawiona, ta wyrażenie musi zwrócić identyfikator organizacji lub true, aby użytkownik mógł uzyskać dostęp do tej organizacji. Gdy nie jest ustawiona, wystarczające jest zdefiniowanie mapowania ról: użytkownik jest dopuszczony, o ile można rozwiązać dla niego ważne mapowanie ról w organizacji.", "defaultMappingsSubmit": "Zapisz domyślne mapowania", "orgPoliciesEdit": "Edytuj politykę organizacji", "org": "Organizacja", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Wykryto międzynarodową domenę", "willbestoredas": "Będą przechowywane jako:", - "roleMappingDescription": "Określ jak role są przypisywane do użytkowników podczas logowania się, gdy automatyczne świadczenie jest włączone.", + "roleMappingDescription": "Określ, jak role są przypisywane użytkownikom podczas logowania się z tym dostawcą tożsamości.", "selectRole": "Wybierz rolę", "roleMappingExpression": "Wyrażenie", "selectRolePlaceholder": "Wybierz rolę", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Cel został pomyślnie zaktualizowany", "httpDestCreatedSuccess": "Cel został utworzony pomyślnie", "httpDestUpdateFailed": "Nie udało się zaktualizować miejsca docelowego", - "httpDestCreateFailed": "Nie udało się utworzyć miejsca docelowego" + "httpDestCreateFailed": "Nie udało się utworzyć miejsca docelowego", + "idpAddActionCreateNew": "Utwórz nowego dostawcę tożsamości", + "idpAddActionImportFromOrg": "Importuj z innej organizacji", + "idpImportDialogTitle": "Importuj dostawcę tożsamości", + "idpImportDialogDescription": "Wybierz dostawcę tożsamości z organizacji, w której jesteś administratorem. Zostanie on powiązany z tą organizacją.", + "idpImportSearchPlaceholder": "Szukaj według nazwy organizacji lub dostawcy...", + "idpImportEmpty": "Nie znaleziono dostawców tożsamości.", + "idpImportedDescription": "Dostawca tożsamości został pomyślnie zaimportowany.", + "idpDeleteGlobalQuestion": "Czy na pewno chcesz trwale usunąć tego dostawcę tożsamości?", + "idpDeleteGlobalDescription": "Spowoduje to trwałe usunięcie dostawcy tożsamości ze wszystkich organizacji, z którymi jest powiązany.", + "idpUnassociateTitle": "Odłącz dostawcę tożsamości", + "idpUnassociateQuestion": "Czy na pewno chcesz odłączyć tego dostawcę tożsamości od tej organizacji?", + "idpUnassociateDescription": "Wszystkie użytkownicy powiązani z tym dostawcą tożsamości zostaną usunięci z tej organizacji, ale dostawca tożsamości będzie nadal istniał dla innych powiązanych organizacji.", + "idpUnassociateConfirm": "Potwierdź odłączenie dostawcy tożsamości", + "idpUnassociateWarning": "Tego nie można cofnąć dla tej organizacji.", + "idpUnassociatedDescription": "Dostawca tożsamości pomyślnie odłączony od tej organizacji", + "idpUnassociateMenu": "Odłącz", + "idpDeleteAllOrgsMenu": "Usuń" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index df7ef9f17..2fa228639 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -898,6 +898,7 @@ "idpDisplayName": "Um nome de exibição para este provedor de identidade", "idpAutoProvisionUsers": "Provisionamento Automático de Utilizadores", "idpAutoProvisionUsersDescription": "Quando ativado, os utilizadores serão criados automaticamente no sistema no primeiro login com a capacidade de mapear utilizadores para funções e organizações.", + "idpAutoProvisionConfigureAfterCreate": "Você pode configurar as definições de auto provisão assim que o provedor de identidade for criado.", "licenseBadge": "EE", "idpType": "Tipo de Provedor", "idpTypeDescription": "Selecione o tipo de provedor de identidade que deseja configurar", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Mapeamento de Função Padrão", "defaultMappingsRoleDescription": "JMESPath para extrair informações de função do token ID. O resultado desta expressão deve retornar o nome da função como definido na organização como uma string.", "defaultMappingsOrg": "Mapeamento de Organização Padrão", - "defaultMappingsOrgDescription": "JMESPath para extrair informações da organização do token ID. Esta expressão deve retornar o ID da organização ou verdadeiro para que o utilizador tenha permissão para aceder à organização.", + "defaultMappingsOrgDescription": "Quando definida, esta expressão deve retornar o ID da organização ou verdadeiro para que o usuário acesse essa organização. Quando não definida, a definição de um mapeamento de papel é suficiente: o usuário é permitido desde que um mapeamento de papel válido possa ser resolvido para ele dentro da organização.", "defaultMappingsSubmit": "Guardar Mapeamentos Padrão", "orgPoliciesEdit": "Editar Política da Organização", "org": "Organização", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Domínio Internacional Detectado", "willbestoredas": "Será armazenado como:", - "roleMappingDescription": "Determinar como as funções são atribuídas aos usuários quando eles fazem login quando Auto Provisão está habilitada.", + "roleMappingDescription": "Determine como os papéis são atribuídos aos usuários quando eles entram com este provedor de identidade.", "selectRole": "Selecione uma função", "roleMappingExpression": "Expressão", "selectRolePlaceholder": "Escolha uma função", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Destino atualizado com sucesso", "httpDestCreatedSuccess": "Destino criado com sucesso", "httpDestUpdateFailed": "Falha ao atualizar destino", - "httpDestCreateFailed": "Falha ao criar destino" + "httpDestCreateFailed": "Falha ao criar destino", + "idpAddActionCreateNew": "Criar novo provedor de identidade", + "idpAddActionImportFromOrg": "Importar de outra organização", + "idpImportDialogTitle": "Importar Provedor de Identidade", + "idpImportDialogDescription": "Escolha um provedor de identidade de uma organização onde você é administrador. Ele será vinculado a esta organização.", + "idpImportSearchPlaceholder": "Pesquisar por nome de organização ou provedor...", + "idpImportEmpty": "Nenhum provedor de identidade encontrado.", + "idpImportedDescription": "Provedor de identidade importado com sucesso.", + "idpDeleteGlobalQuestion": "Tem certeza de que deseja eliminar permanentemente este provedor de identidade?", + "idpDeleteGlobalDescription": "Isso eliminará permanentemente o provedor de identidade de todas as organizações com as quais está associado.", + "idpUnassociateTitle": "Desassociar Provedor de Identidade", + "idpUnassociateQuestion": "Tem certeza de que deseja desassociar este provedor de identidade desta organização?", + "idpUnassociateDescription": "Todos os usuários associados a este provedor de identidade serão removidos desta organização, mas o provedor de identidade continuará a existir para outras organizações associadas.", + "idpUnassociateConfirm": "Confirmar Desassociação do Provedor de Identidade", + "idpUnassociateWarning": "Isso não pode ser desfeito para esta organização.", + "idpUnassociatedDescription": "Provedor de identidade desassociado desta organização com sucesso", + "idpUnassociateMenu": "Desassociar", + "idpDeleteAllOrgsMenu": "Excluir" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 7a8bee8b9..871b292d9 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -898,6 +898,7 @@ "idpDisplayName": "Отображаемое имя для этого поставщика удостоверений", "idpAutoProvisionUsers": "Автоматическое создание пользователей", "idpAutoProvisionUsersDescription": "При включении пользователи будут автоматически создаваться в системе при первом входе с возможностью сопоставления пользователей с ролями и организациями.", + "idpAutoProvisionConfigureAfterCreate": "Вы можете настроить параметры автоматического обеспечения после создания поставщика удостоверений.", "licenseBadge": "EE", "idpType": "Тип поставщика", "idpTypeDescription": "Выберите тип поставщика удостоверений, который вы хотите настроить", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Сопоставление ролей по умолчанию", "defaultMappingsRoleDescription": "Результат этого выражения должен возвращать имя роли, как определено в организации, в виде строки.", "defaultMappingsOrg": "Сопоставление организаций по умолчанию", - "defaultMappingsOrgDescription": "Это выражение должно возвращать ID организации или true для разрешения доступа пользователя к организации.", + "defaultMappingsOrgDescription": "При установке это выражение должно возвращать ID организации или true, чтобы пользователь мог получить доступ к этой организации. При отсутствии настройка отображения роли достаточно: пользователю разрешено войти, пока для него может быть решено отображение гарантированной роли в организации.", "defaultMappingsSubmit": "Сохранить сопоставления по умолчанию", "orgPoliciesEdit": "Редактировать политику организации", "org": "Организация", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Обнаружен международный домен", "willbestoredas": "Будет храниться как:", - "roleMappingDescription": "Определите, как роли, назначаемые пользователям, когда они войдут в систему автоматического профиля.", + "roleMappingDescription": "Определите, как роли присваиваются пользователям при входе с этим поставщиком удостоверений.", "selectRole": "Выберите роль", "roleMappingExpression": "Выражение", "selectRolePlaceholder": "Выберите роль", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Адрес назначения успешно обновлен", "httpDestCreatedSuccess": "Адрес назначения успешно создан", "httpDestUpdateFailed": "Не удалось обновить место назначения", - "httpDestCreateFailed": "Не удалось создать место назначения" + "httpDestCreateFailed": "Не удалось создать место назначения", + "idpAddActionCreateNew": "Создать нового поставщика удостоверений", + "idpAddActionImportFromOrg": "Импортировать из другой организации", + "idpImportDialogTitle": "Импортировать поставщика удостоверений", + "idpImportDialogDescription": "Выберите поставщика удостоверений из организации, где вы являетесь администратором. Он будет связан с этой организацией.", + "idpImportSearchPlaceholder": "Поиск по организации или имени поставщика...", + "idpImportEmpty": "Поставщики удостоверений не найдены.", + "idpImportedDescription": "Поставщик удостоверений успешно импортирован.", + "idpDeleteGlobalQuestion": "Вы уверены, что хотите навсегда удалить этого поставщика удостоверений?", + "idpDeleteGlobalDescription": "Это навсегда удалит поставщика удостоверений из всех организаций, с которыми он связан.", + "idpUnassociateTitle": "Рассоединить провайдера удостоверений", + "idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?", + "idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.", + "idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений", + "idpUnassociateWarning": "Это не может быть отменено для этой организации.", + "idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией", + "idpUnassociateMenu": "Рассоединить", + "idpDeleteAllOrgsMenu": "Удалить" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 68de3399d..754b529ac 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -898,6 +898,7 @@ "idpDisplayName": "Bu kimlik sağlayıcı için bir görüntü adı", "idpAutoProvisionUsers": "Kullanıcıları Otomatik Sağla", "idpAutoProvisionUsersDescription": "Etkinleştirildiğinde, kullanıcılar rol ve organizasyonlara eşleme yeteneğiyle birlikte sistemde otomatik olarak oluşturulacak.", + "idpAutoProvisionConfigureAfterCreate": "Kimlik sağlayıcı oluşturulduktan sonra otomatik sağlama ayarlarını yapılandırabilirsiniz.", "licenseBadge": " ", "idpType": "Sağlayıcı Türü", "idpTypeDescription": "Yapılandırmak istediğiniz kimlik sağlayıcısı türünü seçin", @@ -949,7 +950,7 @@ "defaultMappingsRole": "Varsayılan Rol Eşleme", "defaultMappingsRoleDescription": "JMESPath to extract role information from the ID token. The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Varsayılan Kuruluş Eşleme", - "defaultMappingsOrgDescription": "JMESPath to extract organization information from the ID token. This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsOrgDescription": "Ayarladığınızda, bu ifade kullanıcının o kuruluşa erişmesi için kuruluş kimliğini veya doğru değerini döndürmelidir. Ayarlamadığınızda, rol eşleme tanımlamak yeterlidir: kullanıcı, kuruluş içinde onlar için geçerli bir rol eşlemesi çözümlenebildiği sürece erişime izin verilir.", "defaultMappingsSubmit": "Varsayılan Eşlemeleri Kaydet", "orgPoliciesEdit": "Kuruluş Politikasını Düzenle", "org": "Kuruluş", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi", "willbestoredas": "Şu şekilde depolanacak:", - "roleMappingDescription": "Otomatik Sağlama etkinleştirildiğinde kullanıcıların oturum açarken rollerin nasıl atandığını belirleyin.", + "roleMappingDescription": "Bu kimlik sağlayıcı ile oturum açıldığında kullanıcılara rollerin nasıl atandığını belirleyin.", "selectRole": "Bir Rol Seçin", "roleMappingExpression": "İfade", "selectRolePlaceholder": "Bir rol seçin", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "Hedef başarıyla güncellendi", "httpDestCreatedSuccess": "Hedef başarıyla oluşturuldu", "httpDestUpdateFailed": "Hedef güncellenemedi", - "httpDestCreateFailed": "Hedef oluşturulamadı" + "httpDestCreateFailed": "Hedef oluşturulamadı", + "idpAddActionCreateNew": "Yeni kimlik sağlayıcı oluştur", + "idpAddActionImportFromOrg": "Başka bir kuruluştan içe aktar", + "idpImportDialogTitle": "Kimlik Sağlayıcı İçe Aktar", + "idpImportDialogDescription": "Bir kuruluştan yönetici olduğunuz bir kimlik sağlayıcı seçin. Bu kuruluşla ilişkilendirilecektir.", + "idpImportSearchPlaceholder": "Kuruluş veya sağlayıcı adına göre ara...", + "idpImportEmpty": "Hiçbir kimlik sağlayıcı bulunamadı.", + "idpImportedDescription": "Kimlik sağlayıcı başarıyla içe aktarıldı.", + "idpDeleteGlobalQuestion": "Bu kimlik sağlayıcıyı kalıcı olarak silmek istediğinizden emin misiniz?", + "idpDeleteGlobalDescription": "Bu, kimlik sağlayıcıyı ilişkilendirildiği tüm kuruluşlardan kalıcı olarak silecektir.", + "idpUnassociateTitle": "Kimlik Sağlayıcının İlişkisini Kes", + "idpUnassociateQuestion": "Bu kimlik sağlayıcının bu kuruluştan ilişiğini kesmek istediğinizden emin misiniz?", + "idpUnassociateDescription": "Bu kimlik sağlayıcı ile ilişkilendirilen tüm kullanıcılar bu kuruluştan kaldırılacaktır, ancak kimlik sağlayıcı diğer ilişkilendirilen kuruluşlar için var olmaya devam edecektir.", + "idpUnassociateConfirm": "Kimlik Sağlayıcının İlişkisinin Kesilmesini Onayla", + "idpUnassociateWarning": "Bu işlem bu kuruluş için geri alınamaz.", + "idpUnassociatedDescription": "Kimlik sağlayıcı bu kuruluştan başarıyla ayrıldı", + "idpUnassociateMenu": "İlişkiyi Kes", + "idpDeleteAllOrgsMenu": "Sil" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 955ad7096..038d4cb01 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -898,6 +898,7 @@ "idpDisplayName": "此身份提供商的显示名称", "idpAutoProvisionUsers": "自动提供用户", "idpAutoProvisionUsersDescription": "如果启用,用户将在首次登录时自动在系统中创建,并且能够映射用户到角色和组织。", + "idpAutoProvisionConfigureAfterCreate": "您可以在创建身份提供者后配置自动配置设置。", "licenseBadge": "EE", "idpType": "提供者类型", "idpTypeDescription": "选择您想要配置的身份提供者类型", @@ -949,7 +950,7 @@ "defaultMappingsRole": "默认角色映射", "defaultMappingsRoleDescription": "此表达式的结果必须返回组织中定义的角色名称作为字符串。", "defaultMappingsOrg": "默认组织映射", - "defaultMappingsOrgDescription": "此表达式必须返回 组织ID 或 true 才能允许用户访问组织。", + "defaultMappingsOrgDescription": "设置时,此表达式必须返回组织ID或true才能让用户访问该组织。如果未设置,定义角色映射就足够了:只要在组织内可以为用户找出有效角色映射,用户就被允许进入。", "defaultMappingsSubmit": "保存默认映射", "orgPoliciesEdit": "编辑组织策略", "org": "组织", @@ -2026,7 +2027,7 @@ }, "internationaldomaindetected": "检测到国际域", "willbestoredas": "储存为:", - "roleMappingDescription": "确定当用户启用自动配送时如何分配他们的角色。", + "roleMappingDescription": "确定当用户使用此身份提供者登陆时如何分配角色。", "selectRole": "选择角色", "roleMappingExpression": "表达式", "selectRolePlaceholder": "选择角色", @@ -2899,5 +2900,22 @@ "httpDestUpdatedSuccess": "目标已成功更新", "httpDestCreatedSuccess": "目标创建成功", "httpDestUpdateFailed": "更新目标失败", - "httpDestCreateFailed": "创建目标失败" + "httpDestCreateFailed": "创建目标失败", + "idpAddActionCreateNew": "创建新的身份提供者", + "idpAddActionImportFromOrg": "从另一个组织导入", + "idpImportDialogTitle": "导入身份提供者", + "idpImportDialogDescription": "从您是管理员的组织中选择一个身份提供者。它将关联到本组织。", + "idpImportSearchPlaceholder": "按组织或提供者名称搜索……", + "idpImportEmpty": "未找到身份提供者。", + "idpImportedDescription": "身份提供者已成功导入。", + "idpDeleteGlobalQuestion": "您确定要永久删除此身份提供者吗?", + "idpDeleteGlobalDescription": "这将永久删除与其关联的所有组织中的身份提供者。", + "idpUnassociateTitle": "取消关联身份提供者", + "idpUnassociateQuestion": "您确定要将此身份提供者从此组织中取消关联吗?", + "idpUnassociateDescription": "与此身份提供者关联的所有用户将从该组织中移除,但身份提供者仍会继续存在于关联的其他组织中。", + "idpUnassociateConfirm": "确认取消关联身份提供者", + "idpUnassociateWarning": "此操作无法对该组织撤销。", + "idpUnassociatedDescription": "身份提供者已成功从该组织中取消关联", + "idpUnassociateMenu": "取消关联", + "idpDeleteAllOrgsMenu": "删除" } diff --git a/public/idp/openid.png b/public/idp/openid.png new file mode 100644 index 000000000..d4422c872 Binary files /dev/null and b/public/idp/openid.png differ diff --git a/public/screenshots/hero.png b/public/screenshots/hero.png index c33d2924b..918dd755d 100644 Binary files a/public/screenshots/hero.png and b/public/screenshots/hero.png differ diff --git a/public/screenshots/private-resources.png b/public/screenshots/private-resources.png index 7e5b05f40..4a4b50d4e 100644 Binary files a/public/screenshots/private-resources.png and b/public/screenshots/private-resources.png differ diff --git a/public/screenshots/public-resources.png b/public/screenshots/public-resources.png index c33d2924b..918dd755d 100644 Binary files a/public/screenshots/public-resources.png and b/public/screenshots/public-resources.png differ diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png index fae86ceeb..f65707bce 100644 Binary files a/public/screenshots/sites.png and b/public/screenshots/sites.png differ diff --git a/public/screenshots/users.png b/public/screenshots/users.png index 3b47e8bbc..69be0452f 100644 Binary files a/public/screenshots/users.png and b/public/screenshots/users.png differ diff --git a/public/third-party/incidentio.png b/public/third-party/incidentio.png new file mode 100644 index 000000000..e567d31fb Binary files /dev/null and b/public/third-party/incidentio.png differ diff --git a/public/third-party/opsgenie.png b/public/third-party/opsgenie.png new file mode 100644 index 000000000..3a1f5a849 Binary files /dev/null and b/public/third-party/opsgenie.png differ diff --git a/public/third-party/pgd.png b/public/third-party/pgd.png new file mode 100644 index 000000000..b084406a0 Binary files /dev/null and b/public/third-party/pgd.png differ diff --git a/public/third-party/servicenow.png b/public/third-party/servicenow.png new file mode 100644 index 000000000..b3fcca4dc Binary files /dev/null and b/public/third-party/servicenow.png differ diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 89ccd7e37..5ae98f965 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -154,7 +154,10 @@ export enum ActionsEnum { createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", deleteHealthCheck = "deleteHealthCheck", - listHealthChecks = "listHealthChecks" + listHealthChecks = "listHealthChecks", + triggerSiteAlert = "triggerSiteAlert", + triggerResourceAlert = "triggerResourceAlert", + triggerHealthCheckAlert = "triggerHealthCheckAlert" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 598127af8..d930b69d0 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -21,6 +21,7 @@ import { exitNodes, sessions, clients, + resources, siteResources, targetHealthCheck, sites @@ -477,13 +478,21 @@ export const alertRules = pgTable("alertRules", { .$type< | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy" + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle" >() .notNull(), // Nullable depending on eventType enabled: boolean("enabled").notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: boolean("allSites").notNull().default(false), + allHealthChecks: boolean("allHealthChecks").notNull().default(false), + allResources: boolean("allResources").notNull().default(false), lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() @@ -509,6 +518,15 @@ export const alertHealthChecks = pgTable("alertHealthChecks", { }) }); +export const alertResources = pgTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + // Separating channels by type avoids the mixed-shape problem entirely export const alertEmailActions = pgTable("alertEmailActions", { emailActionId: serial("emailActionId").primaryKey(), @@ -530,7 +548,7 @@ export const alertEmailRecipients = pgTable("alertEmailRecipients", { userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: varchar("roleId").references(() => roles.roleId, { + roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), email: varchar("email", { length: 255 }) // external emails not tied to a user @@ -584,3 +602,4 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f064ed906..b61cfcf19 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -194,6 +194,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }).notNull(), name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index e6c904485..9a33e2049 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -13,6 +13,7 @@ import { domains, exitNodes, orgs, + resources, roles, sessions, siteResources, @@ -469,12 +470,20 @@ export const alertRules = sqliteTable("alertRules", { .$type< | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy" + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle" >() .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), + allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false), + allResources: integer("allResources", { mode: "boolean" }).notNull().default(false), lastTriggeredAt: integer("lastTriggeredAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull() @@ -500,6 +509,15 @@ export const alertHealthChecks = sqliteTable("alertHealthChecks", { }) }); +export const alertResources = sqliteTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + export const alertEmailActions = sqliteTable("alertEmailActions", { emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), alertRuleId: integer("alertRuleId") @@ -515,7 +533,7 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", { .notNull() .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: text("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), email: text("email") }); @@ -561,3 +579,4 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 00994fa2a..c5600b756 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -217,6 +217,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }).notNull(), name: text("name"), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 8a0cf7631..418924650 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -15,8 +15,13 @@ import { export type AlertEventType = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; interface Props { eventType: AlertEventType; @@ -50,6 +55,15 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Offline", statusColor: "#dc2626" }; + case "site_toggle": + return { + heading: "Site Status Changed", + previewText: "A site in your organization has changed status.", + summary: + "A site in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; case "health_check_healthy": return { heading: "Health Check Recovered", @@ -60,7 +74,7 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Healthy", statusColor: "#16a34a" }; - case "health_check_not_healthy": + case "health_check_unhealthy": return { heading: "Health Check Failing", previewText: @@ -70,6 +84,53 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Not Healthy", statusColor: "#dc2626" }; + case "health_check_toggle": + return { + heading: "Health Check Status Changed", + previewText: + "A health check in your organization has changed status.", + summary: + "A health check in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; + case "resource_healthy": + return { + heading: "Resource Healthy", + previewText: "A resource in your organization is now healthy.", + summary: + "A resource in your organization has recovered and is now reporting a healthy status.", + statusLabel: "Healthy", + statusColor: "#16a34a" + }; + case "resource_unhealthy": + return { + heading: "Resource Unhealthy", + previewText: "A resource in your organization is not healthy.", + summary: + "A resource in your organization is currently unhealthy. Please review the details below and take action if needed.", + statusLabel: "Unhealthy", + statusColor: "#dc2626" + }; + case "resource_toggle": + return { + heading: "Resource Status Changed", + previewText: + "A resource in your organization has changed status.", + summary: + "A resource in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; + default: + return { + heading: "Alert Notification", + previewText: "An alert event has occurred in your organization.", + summary: + "An alert event has occurred in your organization. Please review the details below and take action if needed.", + statusLabel: "Alert", + statusColor: "#f59e0b" + }; } } diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 72bcda76f..175c8c79f 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -141,7 +141,9 @@ export async function updateProxyResources( .insert(targetHealthCheck) .values({ name: `${targetData.hostname}:${targetData.port}`, + siteId: site.siteId, targetId: newTarget.targetId, + orgId: orgId, hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, hcScheme: healthcheckData?.scheme, diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9ede25fe6..594e27aec 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -55,7 +55,7 @@ export async function fireHealthCheckHealthyAlert( } /** - * Fire a `health_check_not_healthy` alert for the given health check. + * Fire a `health_check_unhealthy` alert for the given health check. * * Call this after a health check has been detected as failing so that any * matching `alertRules` can dispatch their email and webhook actions. @@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert( ): Promise { try { await processAlerts({ - eventType: "health_check_not_healthy", + eventType: "health_check_unhealthy", orgId, healthCheckId, data: { diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 9ede25fe6..5c9b168e8 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -19,73 +19,109 @@ import { processAlerts } from "../processAlerts"; // --------------------------------------------------------------------------- /** - * Fire a `health_check_healthy` alert for the given health check. + * Fire a `resource_healthy` alert for the given resource. * - * Call this after a previously-failing health check has recovered so that any + * Call this after a previously-unhealthy resource has recovered so that any * matching `alertRules` can dispatch their email and webhook actions. * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireHealthCheckHealthyAlert( +export async function fireResourceHealthyAlert( orgId: string, - healthCheckId: number, - healthCheckName?: string | null, + resourceId: number, + resourceName?: string | null, extra?: Record ): Promise { try { await processAlerts({ - eventType: "health_check_healthy", + eventType: "resource_healthy", orgId, - healthCheckId, + resourceId, data: { - healthCheckId, - ...(healthCheckName != null ? { healthCheckName } : {}), + resourceId, + ...(resourceName != null ? { resourceName } : {}), ...extra } }); } catch (err) { logger.error( - `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`, err ); } } /** - * Fire a `health_check_not_healthy` alert for the given health check. + * Fire a `resource_unhealthy` alert for the given resource. * - * Call this after a health check has been detected as failing so that any + * Call this after a resource has been detected as unhealthy so that any * matching `alertRules` can dispatch their email and webhook actions. * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireHealthCheckNotHealthyAlert( +export async function fireResourceUnhealthyAlert( orgId: string, - healthCheckId: number, - healthCheckName?: string | null, + resourceId: number, + resourceName?: string | null, extra?: Record ): Promise { try { await processAlerts({ - eventType: "health_check_not_healthy", + eventType: "resource_unhealthy", orgId, - healthCheckId, + resourceId, data: { - healthCheckId, - ...(healthCheckName != null ? { healthCheckName } : {}), + resourceId, + ...(resourceName != null ? { resourceName } : {}), ...extra } }); } catch (err) { logger.error( - `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`, err ); } } + +/** + * Fire a `resource_toggle` alert for the given resource. + * + * Call this when a resource's enabled/disabled status is toggled so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceToggleAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index 04e3f90fd..5e098a1f2 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -11,12 +11,13 @@ * This file is not licensed under the AGPLv3. */ -import { and, eq, isNull, or } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { db } from "@server/db"; import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions, @@ -48,11 +49,9 @@ export async function processAlerts(context: AlertContext): Promise { // ------------------------------------------------------------------ // 1. Find matching alert rules // ------------------------------------------------------------------ - // Rules with no junction-table entries match ALL sites / health checks. - // Rules with junction entries match only those specific IDs. - // We implement this with a LEFT JOIN: a NULL join result means the rule - // has no scope restrictions (match all); a non-NULL result that satisfies - // the id equality filter means an explicit match. + // Rules with allSites / allHealthChecks / allResources set to true match + // ANY event of that type. Rules without these flags set match only the + // specific IDs listed in the junction tables. const baseConditions = and( eq(alertRules.orgId, context.orgId), eq(alertRules.eventType, context.eventType), @@ -73,12 +72,20 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertSites.siteId, context.siteId), - isNull(alertSites.alertRuleId) + eq(alertRules.allSites, true), + eq(alertSites.siteId, context.siteId) ) ) ); - rules = rows.map((r) => r.alertRules); + // Deduplicate in case a rule matched on multiple junction rows + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else if (context.healthCheckId != null) { const rows = await db .select() @@ -91,12 +98,44 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertHealthChecks.healthCheckId, context.healthCheckId), - isNull(alertHealthChecks.alertRuleId) + eq(alertRules.allHealthChecks, true), + eq(alertHealthChecks.healthCheckId, context.healthCheckId) ) ) ); - rules = rows.map((r) => r.alertRules); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); + } else if (context.resourceId != null) { + const rows = await db + .select() + .from(alertRules) + .leftJoin( + alertResources, + eq(alertResources.alertRuleId, alertRules.alertRuleId) + ) + .where( + and( + baseConditions, + or( + eq(alertRules.allResources, true), + eq(alertResources.resourceId, context.resourceId) + ) + ) + ); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else { rules = []; } diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index cd78e6e87..634598158 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -72,10 +72,20 @@ function buildSubject(context: AlertContext): string { return "[Alert] Site Back Online"; case "site_offline": return "[Alert] Site Offline"; + case "site_toggle": + return "[Alert] Site Status Changed"; case "health_check_healthy": return "[Alert] Health Check Recovered"; - case "health_check_not_healthy": + case "health_check_unhealthy": return "[Alert] Health Check Failing"; + case "health_check_toggle": + return "[Alert] Health Check Status Changed"; + case "resource_healthy": + return "[Alert] Resource Healthy"; + case "resource_unhealthy": + return "[Alert] Resource Unhealthy"; + case "resource_toggle": + return "[Alert] Resource Status Changed"; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. @@ -84,4 +94,4 @@ function buildSubject(context: AlertContext): string { return "[Alert] Event Notification"; } } -} \ No newline at end of file +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index e79db2ef5..0679b7ece 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -18,8 +18,13 @@ export type AlertEventType = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; // --------------------------------------------------------------------------- // Webhook authentication config (stored as encrypted JSON in the DB) @@ -58,6 +63,8 @@ export interface AlertContext { siteId?: number; /** Set for health_check_* events */ healthCheckId?: number; + /** Set for resource_* events */ + resourceId?: number; /** Human-readable context data included in emails and webhook payloads */ data: Record; -} \ No newline at end of file +} diff --git a/server/private/routers/alertEvents/index.ts b/server/private/routers/alertEvents/index.ts new file mode 100644 index 000000000..485b434eb --- /dev/null +++ b/server/private/routers/alertEvents/index.ts @@ -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 "./triggerSiteAlert"; +export * from "./triggerResourceAlert"; +export * from "./triggerHealthCheckAlert"; \ No newline at end of file diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts new file mode 100644 index 000000000..246de8cd0 --- /dev/null +++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts @@ -0,0 +1,129 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { targetHealthCheck, statusHistory } 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 { eq, and } from "drizzle-orm"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckNotHealthyAlert +} from "#private/lib/alerts/events/healthCheckEvents"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + healthCheckId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["health_check_healthy", "health_check_unhealthy"]) +}); + +export type TriggerHealthCheckAlertResponse = { + success: true; +}; + +export async function triggerHealthCheckAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, healthCheckId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the health check exists and belongs to the org + const [healthCheck] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheckId + ), + eq(targetHealthCheck.orgId, orgId) + ) + ) + .limit(1); + + if (!healthCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check ${healthCheckId} not found in organization ${orgId}` + ) + ); + } + + await db.insert(statusHistory).values({ + entityType: "healthCheck", + entityId: healthCheckId, + orgId, + status: eventType === "health_check_healthy" ? "healthy" : "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + + if (eventType === "health_check_healthy") { + await fireHealthCheckHealthyAlert( + orgId, + healthCheckId, + healthCheck.name ?? undefined + ); + } else { + await fireHealthCheckNotHealthyAlert( + orgId, + healthCheckId, + healthCheck.name ?? undefined + ); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts new file mode 100644 index 000000000..61b81d900 --- /dev/null +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -0,0 +1,135 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources, statusHistory } 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 { eq, and } from "drizzle-orm"; +import { + fireResourceHealthyAlert, + fireResourceUnhealthyAlert, + fireResourceToggleAlert +} from "#private/lib/alerts/events/resourceEvents"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + resourceId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"]) +}); + +export type TriggerResourceAlertResponse = { + success: true; +}; + +export async function triggerResourceAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, resourceId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the resource exists and belongs to the org + const [resource] = await db + .select() + .from(resources) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource ${resourceId} not found in organization ${orgId}` + ) + ); + } + + if (eventType === "resource_healthy" || eventType === "resource_unhealthy") { + await db.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId, + status: eventType === "resource_healthy" ? "healthy" : "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + } + + if (eventType === "resource_healthy") { + await fireResourceHealthyAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } else if (eventType === "resource_unhealthy") { + await fireResourceUnhealthyAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } else { + await fireResourceToggleAlert( + orgId, + resourceId, + resource.name ?? undefined + ); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertEvents/triggerSiteAlert.ts b/server/private/routers/alertEvents/triggerSiteAlert.ts new file mode 100644 index 000000000..084fbc758 --- /dev/null +++ b/server/private/routers/alertEvents/triggerSiteAlert.ts @@ -0,0 +1,113 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { sites, statusHistory } 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 { eq, and } from "drizzle-orm"; +import { + fireSiteOnlineAlert, + fireSiteOfflineAlert +} from "#private/lib/alerts/events/siteEvents"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + siteId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + eventType: z.enum(["site_online", "site_offline"]) +}); + +export type TriggerSiteAlertResponse = { + success: true; +}; + +export async function triggerSiteAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId, siteId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + const { eventType } = parsedBody.data; + + // Verify the site exists and belongs to the org + const [site] = await db + .select() + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site ${siteId} not found in organization ${orgId}` + ) + ); + } + + await db.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId, + status: eventType === "site_online" ? "online" : "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + + if (eventType === "site_online") { + await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined); + } else { + await fireSiteOfflineAlert(orgId, siteId, site.name ?? undefined); + } + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 40408d898..8a31327ab 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -13,11 +13,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, roles } from "@server/db"; import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -31,10 +32,16 @@ import { OpenAPITags, registry } from "@server/openApi"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; -const HC_EVENT_TYPES = [ +export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const; +export const HC_EVENT_TYPES = [ "health_check_healthy", - "health_check_not_healthy" + "health_check_unhealthy", + "health_check_toggle" +] as const; +export const RESOURCE_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_toggle" ] as const; const paramsSchema = z.strictObject({ @@ -51,22 +58,28 @@ const bodySchema = z .strictObject({ name: z.string().nonempty(), eventType: z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES ]), enabled: z.boolean().optional().default(true), cooldownSeconds: z.number().int().nonnegative().optional().default(300), // Source join tables - which is required depends on eventType siteIds: z.array(z.number().int().positive()).optional().default([]), + allSites: z.boolean().optional().default(false), healthCheckIds: z .array(z.number().int().positive()) .optional() .default([]), + allHealthChecks: z.boolean().optional().default(false), + resourceIds: z + .array(z.number().int().positive()) + .optional() + .default([]), + allResources: z.boolean().optional().default(false), // Email recipients (flat) userIds: z.array(z.string().nonempty()).optional().default([]), - roleIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number()).optional().default([]), emails: z.array(z.string().email()).optional().default([]), // Webhook actions webhookActions: z.array(webhookActionSchema).optional().default([]) @@ -78,21 +91,23 @@ const bodySchema = z const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( val.eventType ); + const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); - if (isSiteEvent && val.siteIds.length === 0) { + if (isSiteEvent && !val.allSites && val.siteIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "At least one siteId is required for site event types", + message: "At least one siteId is required for site event types when allSites is false", path: ["siteIds"] }); } - if (isHcEvent && val.healthCheckIds.length === 0) { + if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "At least one healthCheckId is required for health check event types", + "At least one healthCheckId is required for health check event types when allHealthChecks is false", path: ["healthCheckIds"] }); } @@ -108,11 +123,50 @@ const bodySchema = z if (isHcEvent && val.siteIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: - "siteIds must not be set for health check event types", + message: "siteIds must not be set for health check event types", path: ["siteIds"] }); } + + if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one resourceId is required for resource event types when allResources is false", + path: ["resourceIds"] + }); + } + + if (isResourceEvent && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } + + if (isSiteEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resourceIds must not be set for site event types", + path: ["resourceIds"] + }); + } + + if (isHcEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resourceIds must not be set for health check event types", + path: ["resourceIds"] + }); + } }); export type CreateAlertRuleResponse = { @@ -171,7 +225,11 @@ export async function createAlertRule( enabled, cooldownSeconds, siteIds, + allSites, healthCheckIds, + allHealthChecks, + resourceIds, + allResources, userIds, roleIds, emails, @@ -188,13 +246,16 @@ export async function createAlertRule( eventType, enabled, cooldownSeconds, + allSites, + allHealthChecks, + allResources, createdAt: now, updatedAt: now }) .returning(); - // Insert site associations - if (siteIds.length > 0) { + // Insert site associations (skipped when allSites=true — empty junction = match all) + if (!allSites && siteIds.length > 0) { await db.insert(alertSites).values( siteIds.map((siteId) => ({ alertRuleId: rule.alertRuleId, @@ -203,8 +264,8 @@ export async function createAlertRule( ); } - // Insert health check associations - if (healthCheckIds.length > 0) { + // Insert health check associations (skipped when allHealthChecks=true) + if (!allHealthChecks && healthCheckIds.length > 0) { await db.insert(alertHealthChecks).values( healthCheckIds.map((healthCheckId) => ({ alertRuleId: rule.alertRuleId, @@ -213,10 +274,22 @@ export async function createAlertRule( ); } + // Insert resource associations (skipped when allResources=true) + if (!allResources && resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId: rule.alertRuleId, + resourceId + })) + ); + } + // Create the email action pivot row and recipients if any recipients // were supplied (userIds, roleIds, or raw emails). const hasRecipients = - userIds.length > 0 || roleIds.length > 0 || emails.length > 0; + userIds.length > 0 || + roleIds.length > 0 || + emails.length > 0; if (hasRecipients) { const [emailActionRow] = await db @@ -228,7 +301,7 @@ export async function createAlertRule( ...userIds.map((userId) => ({ emailActionId: emailActionRow.emailActionId, userId, - roleId: null as string | null, + roleId: null as number | null, email: null as string | null })), ...roleIds.map((roleId) => ({ @@ -240,7 +313,7 @@ export async function createAlertRule( ...emails.map((email) => ({ emailActionId: emailActionRow.emailActionId, userId: null as string | null, - roleId: null as string | null, + roleId: null as number | null, email })) ]; @@ -254,7 +327,10 @@ export async function createAlertRule( webhookActions.map((wa) => ({ alertRuleId: rule.alertRuleId, webhookUrl: wa.webhookUrl, - config: wa.config != null ? encrypt(wa.config, serverSecret) : null, + config: + wa.config != null + ? encrypt(wa.config, serverSecret) + : null, enabled: wa.enabled })) ); @@ -275,4 +351,4 @@ export async function createAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 5d307316b..06a97e880 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { WebhookAlertConfig } from "@server/lib/alerts/types"; +import { WebhookAlertConfig } from "#private/lib/alerts/types"; const paramsSchema = z .object({ @@ -47,8 +48,13 @@ export type GetAlertRuleResponse = { eventType: | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; @@ -56,10 +62,11 @@ export type GetAlertRuleResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; recipients: { recipientId: number; userId: string | null; - roleId: string | null; + roleId: number | null; email: string | null; }[]; webhookActions: { @@ -128,6 +135,12 @@ export async function getAlertRule( .from(alertHealthChecks) .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + // Fetch resource associations + const resourceRows = await db + .select() + .from(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + // Resolve the single email action row for this rule, then collect all // recipients into a flat list. The emailAction pivot row is an internal // implementation detail and is not surfaced to callers. @@ -175,26 +188,30 @@ export async function getAlertRule( updatedAt: rule.updatedAt, siteIds: siteRows.map((r) => r.siteId), healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), + resourceIds: resourceRows.map((r) => r.resourceId), recipients, webhookActions: webhooks.map((w) => { - let parsedConfig: WebhookAlertConfig | null = null; - if (w.config) { - try { - const serverSecret = config.getRawConfig().server.secret!; - const decrypted = decrypt(w.config, serverSecret); - parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig; - } catch { - // best-effort – return null if decryption fails - } - } - return { - webhookActionId: w.webhookActionId, - webhookUrl: w.webhookUrl, - enabled: w.enabled, - lastSentAt: w.lastSentAt ?? null, - config: parsedConfig - }; - }) + let parsedConfig: WebhookAlertConfig | null = null; + if (w.config) { + try { + const serverSecret = + config.getRawConfig().server.secret!; + const decrypted = decrypt(w.config, serverSecret); + parsedConfig = JSON.parse( + decrypted + ) as WebhookAlertConfig; + } catch { + // best-effort – return null if decryption fails + } + } + return { + webhookActionId: w.webhookActionId, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null, + config: parsedConfig + }; + }) }, success: true, error: false, @@ -207,4 +224,4 @@ export async function getAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index e5e0053c9..601ab0fa3 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -14,14 +14,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules, alertSites, alertHealthChecks } from "@server/db"; +import { alertRules, alertSites, alertHealthChecks, alertResources } 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 { OpenAPITags, registry } from "@server/openApi"; -import { eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, like, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -39,7 +39,18 @@ const querySchema = z.strictObject({ .optional() .default("0") .transform(Number) - .pipe(z.number().int().nonnegative()) + .pipe(z.number().int().nonnegative()), + query: z.string().optional(), + siteId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()), + resourceId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()) }); export type ListAlertRulesResponse = { @@ -55,6 +66,7 @@ export type ListAlertRulesResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; }[]; pagination: { total: number; @@ -101,12 +113,69 @@ export async function listAlertRules( ) ); } - const { limit, offset } = parsedQuery.data; + const { limit, offset, query, siteId, resourceId } = parsedQuery.data; + + // Resolve siteId filter → matching alertRuleIds + let siteFilterRuleIds: number[] | null = null; + if (siteId !== undefined) { + const rows = await db + .select({ alertRuleId: alertSites.alertRuleId }) + .from(alertSites) + .where(eq(alertSites.siteId, siteId)); + siteFilterRuleIds = rows.map((r) => r.alertRuleId); + if (siteFilterRuleIds.length === 0) { + return response(res, { + data: { + alertRules: [], + pagination: { total: 0, limit, offset } + }, + success: true, + error: false, + message: "Alert rules retrieved successfully", + status: HttpCode.OK + }); + } + } + + // Resolve resourceId filter → matching alertRuleIds + let resourceFilterRuleIds: number[] | null = null; + if (resourceId !== undefined) { + const rows = await db + .select({ alertRuleId: alertResources.alertRuleId }) + .from(alertResources) + .where(eq(alertResources.resourceId, resourceId)); + resourceFilterRuleIds = rows.map((r) => r.alertRuleId); + if (resourceFilterRuleIds.length === 0) { + return response(res, { + data: { + alertRules: [], + pagination: { total: 0, limit, offset } + }, + success: true, + error: false, + message: "Alert rules retrieved successfully", + status: HttpCode.OK + }); + } + } + + const whereClause = and( + eq(alertRules.orgId, orgId), + query + ? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`) + : undefined, + siteFilterRuleIds !== null + ? inArray(alertRules.alertRuleId, siteFilterRuleIds) + : undefined, + resourceFilterRuleIds !== null + ? inArray(alertRules.alertRuleId, resourceFilterRuleIds) + : undefined + ); const list = await db .select() .from(alertRules) - .where(eq(alertRules.orgId, orgId)) + .where(whereClause) .orderBy(sql`${alertRules.createdAt} DESC`) .limit(limit) .offset(offset); @@ -114,7 +183,7 @@ export async function listAlertRules( const [{ count }] = await db .select({ count: sql`count(*)` }) .from(alertRules) - .where(eq(alertRules.orgId, orgId)); + .where(whereClause); // Batch-fetch site and health-check associations for all returned rules // in two queries rather than N+1 individual lookups. @@ -138,6 +207,14 @@ export async function listAlertRules( ) : []; + const resourceRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertResources) + .where(inArray(alertResources.alertRuleId, ruleIds)) + : []; + // Index by alertRuleId for O(1) lookup when building the response const sitesByRule = new Map(); for (const row of siteRows) { @@ -153,6 +230,13 @@ export async function listAlertRules( healthChecksByRule.set(row.alertRuleId, existing); } + const resourcesByRule = new Map(); + for (const row of resourceRows) { + const existing = resourcesByRule.get(row.alertRuleId) ?? []; + existing.push(row.resourceId); + resourcesByRule.set(row.alertRuleId, existing); + } + return response(res, { data: { alertRules: list.map((rule) => ({ @@ -167,7 +251,8 @@ export async function listAlertRules( updatedAt: rule.updatedAt, siteIds: sitesByRule.get(rule.alertRuleId) ?? [], healthCheckIds: - healthChecksByRule.get(rule.alertRuleId) ?? [] + healthChecksByRule.get(rule.alertRuleId) ?? [], + resourceIds: resourcesByRule.get(rule.alertRuleId) ?? [] })), pagination: { total: count, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index add031dc4..358661ac9 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -31,12 +32,8 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; - -const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; -const HC_EVENT_TYPES = [ - "health_check_healthy", - "health_check_not_healthy" -] as const; +import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule"; +import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode"; const paramsSchema = z .object({ @@ -57,20 +54,23 @@ const bodySchema = z name: z.string().nonempty().optional(), eventType: z .enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES ]) .optional(), enabled: z.boolean().optional(), cooldownSeconds: z.number().int().nonnegative().optional(), // Source join tables - if provided the full set is replaced siteIds: z.array(z.number().int().positive()).optional(), + allSites: z.boolean().optional(), healthCheckIds: z.array(z.number().int().positive()).optional(), + allHealthChecks: z.boolean().optional(), + resourceIds: z.array(z.number().int().positive()).optional(), + allResources: z.boolean().optional(), // Recipient arrays - if any are provided the full recipient set is replaced userIds: z.array(z.string().nonempty()).optional(), - roleIds: z.array(z.string().nonempty()).optional(), + roleIds: z.array(z.number()).optional(), emails: z.array(z.string().email()).optional(), // Webhook actions - if provided the full webhook set is replaced webhookActions: z.array(webhookActionSchema).optional() @@ -84,6 +84,33 @@ const bodySchema = z const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( val.eventType ); + const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); + + if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one siteId is required for site event types when allSites is false", + path: ["siteIds"] + }); + } + + if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one healthCheckId is required for health check event types when allHealthChecks is false", + path: ["healthCheckIds"] + }); + } + + if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one resourceId is required for resource event types when allResources is false", + path: ["resourceIds"] + }); + } if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { ctx.addIssue({ @@ -100,6 +127,22 @@ const bodySchema = z path: ["siteIds"] }); } + + if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } }); export type UpdateAlertRuleResponse = { @@ -174,7 +217,11 @@ export async function updateAlertRule( enabled, cooldownSeconds, siteIds, + allSites, healthCheckIds, + allHealthChecks, + resourceIds, + allResources, userIds, roleIds, emails, @@ -189,8 +236,10 @@ export async function updateAlertRule( if (name !== undefined) updateData.name = name; if (eventType !== undefined) updateData.eventType = eventType; if (enabled !== undefined) updateData.enabled = enabled; - if (cooldownSeconds !== undefined) - updateData.cooldownSeconds = cooldownSeconds; + if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; + if (allSites !== undefined) updateData.allSites = allSites; + if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks; + if (allResources !== undefined) updateData.allResources = allResources; await db .update(alertRules) @@ -203,12 +252,14 @@ export async function updateAlertRule( ); // --- Full-replace site associations if siteIds was provided --- - if (siteIds !== undefined) { + if (siteIds !== undefined || allSites !== undefined) { await db .delete(alertSites) .where(eq(alertSites.alertRuleId, alertRuleId)); - if (siteIds.length > 0) { + // Only insert junction rows when allSites is not true + const effectiveAllSites = allSites ?? false; + if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) { await db.insert(alertSites).values( siteIds.map((siteId) => ({ alertRuleId, @@ -219,12 +270,13 @@ export async function updateAlertRule( } // --- Full-replace health check associations if healthCheckIds was provided --- - if (healthCheckIds !== undefined) { + if (healthCheckIds !== undefined || allHealthChecks !== undefined) { await db .delete(alertHealthChecks) .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); - if (healthCheckIds.length > 0) { + const effectiveAllHealthChecks = allHealthChecks ?? false; + if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) { await db.insert(alertHealthChecks).values( healthCheckIds.map((healthCheckId) => ({ alertRuleId, @@ -234,6 +286,23 @@ export async function updateAlertRule( } } + // --- Full-replace resource associations if resourceIds was provided --- + if (resourceIds !== undefined || allResources !== undefined) { + await db + .delete(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + + const effectiveAllResources = allResources ?? false; + if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId, + resourceId + })) + ); + } + } + // --- Full-replace recipients if any recipient array was provided --- const recipientsProvided = userIds !== undefined || @@ -244,7 +313,7 @@ export async function updateAlertRule( const newRecipients = [ ...(userIds ?? []).map((userId) => ({ userId, - roleId: null as string | null, + roleId: null as number | null, email: null as string | null })), ...(roleIds ?? []).map((roleId) => ({ @@ -254,7 +323,7 @@ export async function updateAlertRule( })), ...(emails ?? []).map((email) => ({ userId: null as string | null, - roleId: null as string | null, + roleId: null as number | null, email })) ]; @@ -331,4 +400,4 @@ export async function updateAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index f7a4c71ab..159ee2449 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -42,7 +42,9 @@ import { verifyRoleAccess, verifyUserAccess, verifyUserCanSetUserOrgRoles, - verifySiteProvisioningKeyAccess + verifySiteProvisioningKeyAccess, + verifyIsLoggedInUser, + verifyAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -89,6 +91,7 @@ authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createIdp), @@ -96,10 +99,23 @@ authenticated.put( orgIdp.createOrgOidcIdp ); +authenticated.post( + "/org/:orgId/idp/:idpId/import", + verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, + verifyOrgAccess, + verifyLimits, + verifyAdmin, + logActionAudit(ActionsEnum.createIdp), + orgIdp.importOrgIdp +); + authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyIdpAccess, verifyLimits, @@ -111,6 +127,7 @@ authenticated.post( authenticated.delete( "/org/:orgId/idp/:idpId", verifyValidLicense, + orgIdp.requireOrgIdentityProviderMode, verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), @@ -118,6 +135,17 @@ authenticated.delete( orgIdp.deleteOrgIdp ); +authenticated.delete( + "/org/:orgId/idp/:idpId/association", + verifyValidLicense, + orgIdp.requireOrgIdentityProviderMode, + verifyOrgAccess, + verifyIdpAccess, + verifyUserHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.unassociateOrgIdp +); + authenticated.get( "/org/:orgId/idp/:idpId", verifyValidLicense, @@ -127,16 +155,14 @@ authenticated.get( orgIdp.getOrgIdp ); -authenticated.get( - "/org/:orgId/idp", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listIdps), - orgIdp.listOrgIdps -); - authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids +authenticated.get( + "/user/:userId/admin-org-idps", + verifyIsLoggedInUser, + orgIdp.listUserAdminOrgIdps +); + authenticated.get( "/org/:orgId/certificate/:domainId/:domain", verifyValidLicense, diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index 2a6028ea8..ff5495e55 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -13,13 +13,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, newts, sites } 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 { OpenAPITags, registry } from "@server/openApi"; +import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -27,6 +29,7 @@ const paramsSchema = z.strictObject({ const bodySchema = z.strictObject({ name: z.string().nonempty(), + siteId: z.number().int().positive(), hcEnabled: z.boolean().default(false), hcMode: z.string().default("http"), hcHostname: z.string().optional(), @@ -97,6 +100,7 @@ export async function createHealthCheck( const { name, + siteId, hcEnabled, hcMode, hcHostname, @@ -120,6 +124,7 @@ export async function createHealthCheck( .values({ targetId: null, orgId, + siteId, name, hcEnabled, hcMode, @@ -140,6 +145,31 @@ export async function createHealthCheck( }) .returning(); + // Push health check to newt if the site is a newt site + if (siteId) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (site && site.type === "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (newt) { + await addStandaloneHealthCheck( + newt.newtId, + record, + newt.version + ); + } + } + } + return response(res, { data: { targetHealthCheckId: record.targetHealthCheckId diff --git a/server/private/routers/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts index b65e4a701..530653aab 100644 --- a/server/private/routers/healthChecks/deleteHealthCheck.ts +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -13,7 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, newts, sites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -21,6 +21,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, isNull } from "drizzle-orm"; +import { removeStandaloneHealthCheck } from "@server/routers/newt/targets"; const paramsSchema = z .object({ @@ -91,6 +92,21 @@ export async function deleteHealthCheck( ) ); + // Remove health check from newt if the site is a newt site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, existing.siteId)) + .limit(1); + + if (newt) { + await removeStandaloneHealthCheck( + newt.newtId, + healthCheckId, + newt.version + ); + } + return response(res, { data: null, success: true, diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index f010c8ed7..5b1ddcfb0 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -43,7 +43,7 @@ export async function getHealthCheckStatusHistory( } const entityType = "healthCheck"; - const entityId = parsedParams.data.healthCheckId + const entityId = parsedParams.data.healthCheckId; const { days } = parsedQuery.data; const nowSec = Math.floor(Date.now() / 1000); diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index b2e6949a1..e156573e4 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -11,13 +11,13 @@ * This file is not licensed under the AGPLv3. */ -import { db, targetHealthCheck, targets, resources } from "@server/db"; +import { db, targetHealthCheck, targets, resources, sites } 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 { OpenAPITags, registry } from "@server/openApi"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, like, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { fromError } from "zod-validation-error"; @@ -39,7 +39,8 @@ const querySchema = z.object({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + query: z.string().optional() }); registry.registerPath({ @@ -80,16 +81,25 @@ export async function listHealthChecks( ) ); } - const { limit, offset } = parsedQuery.data; + const { limit, offset, query } = parsedQuery.data; const whereClause = and( eq(targetHealthCheck.orgId, orgId), + query + ? like( + sql`LOWER(${targetHealthCheck.name})`, + `%${query.toLowerCase()}%` + ) + : undefined ); const list = await db .select({ targetHealthCheckId: targetHealthCheck.targetHealthCheckId, name: targetHealthCheck.name, + siteId: targetHealthCheck.siteId, + siteName: sites.name, + siteNiceId: sites.niceId, hcEnabled: targetHealthCheck.hcEnabled, hcHealth: targetHealthCheck.hcHealth, hcMode: targetHealthCheck.hcMode, @@ -114,6 +124,7 @@ export async function listHealthChecks( .from(targetHealthCheck) .leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId)) .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targetHealthCheck.siteId, sites.siteId)) .where(whereClause) .orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`) .limit(limit) @@ -129,6 +140,9 @@ export async function listHealthChecks( healthChecks: list.map((row) => ({ targetHealthCheckId: row.targetHealthCheckId, name: row.name ?? "", + siteId: row.siteId ?? null, + siteName: row.siteName ?? null, + siteNiceId: row.siteNiceId ?? null, hcEnabled: row.hcEnabled, hcHealth: (row.hcHealth ?? "unknown") as | "unknown" diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index c5a0759b7..713bf1e03 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -13,7 +13,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, newts, sites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -21,6 +21,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, isNull } from "drizzle-orm"; +import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; const paramsSchema = z .object({ @@ -34,6 +35,7 @@ const paramsSchema = z const bodySchema = z.strictObject({ name: z.string().nonempty().optional(), + siteId: z.number().int().positive().optional(), hcEnabled: z.boolean().optional(), hcMode: z.string().optional(), hcHostname: z.string().optional(), @@ -55,6 +57,7 @@ const bodySchema = z.strictObject({ export type UpdateHealthCheckResponse = { targetHealthCheckId: number; name: string | null; + siteId: number | null; hcEnabled: boolean; hcHealth: string | null; hcMode: string | null; @@ -125,10 +128,7 @@ export async function updateHealthCheck( .from(targetHealthCheck) .where( and( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheckId - ), + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), eq(targetHealthCheck.orgId, orgId), isNull(targetHealthCheck.targetId) ) @@ -145,6 +145,7 @@ export async function updateHealthCheck( const { name, + siteId, hcEnabled, hcMode, hcHostname, @@ -166,6 +167,7 @@ export async function updateHealthCheck( const updateData: Record = {}; if (name !== undefined) updateData.name = name; + if (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; if (hcMode !== undefined) updateData.hcMode = hcMode; if (hcHostname !== undefined) updateData.hcHostname = hcHostname; @@ -193,19 +195,28 @@ export async function updateHealthCheck( .set(updateData) .where( and( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheckId - ), + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), eq(targetHealthCheck.orgId, orgId), isNull(targetHealthCheck.targetId) ) ) .returning(); + // Push updated health check to newt if the site is a newt site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, updated.siteId)) + .limit(1); + + if (newt) { + await addStandaloneHealthCheck(newt.newtId, updated, newt.version); + } + return response(res, { data: { targetHealthCheckId: updated.targetHealthCheckId, + siteId: updated.siteId ?? null, name: updated.name ?? null, hcEnabled: updated.hcEnabled, hcHealth: updated.hcHealth ?? null, diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 0fa526bc0..ed97b3751 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -14,6 +14,7 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; +import * as alertEvents from "#private/routers/alertEvents"; import { verifyApiKeyHasAction, @@ -40,6 +41,27 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; export const unauthenticated = ua; export const authenticated = a; +authenticated.post( + "/org/:orgId/site/:siteId/trigger-alert", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.triggerSiteAlert), + alertEvents.triggerSiteAlert +); + +authenticated.post( + "/org/:orgId/resource/:resourceId/trigger-alert", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.triggerResourceAlert), + alertEvents.triggerResourceAlert +); + +authenticated.post( + "/org/:orgId/health-check/:healthCheckId/trigger-alert", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.triggerHealthCheckAlert), + alertEvents.triggerHealthCheckAlert +); + authenticated.post( `/org/:orgId/send-usage-notification`, verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index b14348a2a..97928d99f 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -27,7 +27,6 @@ import config from "@server/lib/config"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); @@ -45,6 +44,7 @@ const bodySchema = z.strictObject({ autoProvision: z.boolean().optional(), variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"), roleMapping: z.string().optional(), + orgMapping: z.string().nullish(), tags: z.string().optional() }); @@ -94,18 +94,6 @@ export async function createOrgOidcIdp( ); } - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - const { clientId, clientSecret, @@ -118,6 +106,7 @@ export async function createOrgOidcIdp( name, variant, roleMapping, + orgMapping: orgMappingBody, tags } = parsedBody.data; @@ -169,7 +158,7 @@ export async function createOrgOidcIdp( idpId: idpRes.idpId, orgId: orgId, roleMapping: roleMapping || null, - orgMapping: `'${orgId}'` + orgMapping: orgMappingBody }); }); diff --git a/server/private/routers/orgIdp/deleteOrgIdp.ts b/server/private/routers/orgIdp/deleteOrgIdp.ts index 304826cd1..9e5dfccee 100644 --- a/server/private/routers/orgIdp/deleteOrgIdp.ts +++ b/server/private/routers/orgIdp/deleteOrgIdp.ts @@ -22,7 +22,6 @@ import { fromError } from "zod-validation-error"; import { idp, idpOidcConfig, idpOrg } from "@server/db"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import privateConfig from "#private/lib/config"; const paramsSchema = z .object({ @@ -60,18 +59,6 @@ export async function deleteOrgIdp( const { idpId } = parsedParams.data; - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - // Check if IDP exists const [existingIdp] = await db .select() diff --git a/server/private/routers/orgIdp/importOrgIdp.ts b/server/private/routers/orgIdp/importOrgIdp.ts new file mode 100644 index 000000000..1f4f5ddd9 --- /dev/null +++ b/server/private/routers/orgIdp/importOrgIdp.ts @@ -0,0 +1,211 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } 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 { idp, idpOrg, orgs, roles, userOrgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import { checkOrgAccessPolicy } from "#private/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + sourceOrgId: z.string().nonempty() +}); + +async function userIsOrgAdmin( + userId: string, + orgId: string, + session: Request["session"] +): Promise { + const [userOrgRow] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!userOrgRow) { + return false; + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId, + session + }); + if (!policyCheck.allowed || policyCheck.error) { + return false; + } + + const roleIds = await getUserOrgRoleIds(userId, orgId); + if (roleIds.length === 0) { + return false; + } + + const [adminRole] = await db + .select() + .from(roles) + .where(and(inArray(roles.roleId, roleIds), eq(roles.isAdmin, true))) + .limit(1); + + return !!adminRole; +} + +export async function importOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId: targetOrgId, idpId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { sourceOrgId } = parsedBody.data; + + if (sourceOrgId === targetOrgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Source and target organization must be different" + ) + ); + } + + const userId = req.user!.userId; + + const sourceLinked = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, sourceOrgId))) + .limit(1); + + if (sourceLinked.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "IdP not found for the source organization" + ) + ); + } + + const sourceAdmin = await userIsOrgAdmin( + userId, + sourceOrgId, + req.session + ); + if (!sourceAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You must be an organization admin in the source organization where this IdP is linked" + ) + ); + } + + const [targetOrg] = await db + .select({ orgId: orgs.orgId }) + .from(orgs) + .where(eq(orgs.orgId, targetOrgId)) + .limit(1); + + if (!targetOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Target organization not found" + ) + ); + } + + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)) + .limit(1); + + if (!existingIdp) { + return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); + } + + const alreadyTarget = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, targetOrgId))) + .limit(1); + + if (alreadyTarget.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "This IdP is already linked to the target organization" + ) + ); + } + + await db.insert(idpOrg).values({ + idpId, + orgId: targetOrgId, + roleMapping: null, + orgMapping: null + }); + + const redirectUrl = await generateOidcRedirectUrl(idpId, targetOrgId); + + return response(res, { + data: { + idpId, + redirectUrl + }, + success: true, + error: false, + message: "Org IdP imported successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/index.ts b/server/private/routers/orgIdp/index.ts index e3f967f86..192d883a6 100644 --- a/server/private/routers/orgIdp/index.ts +++ b/server/private/routers/orgIdp/index.ts @@ -12,7 +12,11 @@ */ export * from "./createOrgOidcIdp"; +export * from "./importOrgIdp"; export * from "./getOrgIdp"; export * from "./listOrgIdps"; +export * from "./listUserAdminOrgIdps"; export * from "./updateOrgOidcIdp"; export * from "./deleteOrgIdp"; +export * from "./unassociateOrgIdp"; +export * from "./requireOrgIdentityProviderMode"; diff --git a/server/private/routers/orgIdp/listUserAdminOrgIdps.ts b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts new file mode 100644 index 000000000..78faa48fa --- /dev/null +++ b/server/private/routers/orgIdp/listUserAdminOrgIdps.ts @@ -0,0 +1,160 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOidcConfig } from "@server/db"; +import { idp, idpOrg, orgs, roles, userOrgRoles } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +const paramsSchema = z.strictObject({ + userId: z.string().nonempty() +}); + +async function getOrgIdsWhereUserIsAdmin(userId: string): Promise { + const rows = await db + .select({ orgId: userOrgRoles.orgId }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where(and(eq(userOrgRoles.userId, userId), eq(roles.isAdmin, true))); + return [...new Set(rows.map((r) => r.orgId))]; +} + +async function queryIdpsForOrgs( + orgIds: string[], + limit: number, + offset: number +) { + return db + .select({ + idpId: idp.idpId, + orgId: idpOrg.orgId, + orgName: orgs.name, + name: idp.name, + type: idp.type, + variant: idpOidcConfig.variant, + tags: idp.tags + }) + .from(idpOrg) + .where(inArray(idpOrg.orgId, orgIds)) + .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .orderBy(sql`idp.name DESC`) + .limit(limit) + .offset(offset); +} + +async function countIdpsForOrgs(orgIds: string[]) { + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) + .where(inArray(idpOrg.orgId, orgIds)); + return count; +} + +export async function listUserAdminOrgIdps( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { userId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const adminOrgIds = await getOrgIdsWhereUserIsAdmin(userId); + + if (adminOrgIds.length === 0) { + return response(res, { + data: { + idps: [], + pagination: { + total: 0, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } + + const list = await queryIdpsForOrgs(adminOrgIds, limit, offset); + const total = await countIdpsForOrgs(adminOrgIds); + + return response(res, { + data: { + idps: list, + pagination: { + total, + limit, + offset + } + }, + success: true, + error: false, + message: "Org Idps retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts new file mode 100644 index 000000000..7942af123 --- /dev/null +++ b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts @@ -0,0 +1,34 @@ +/* + * 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 { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import privateConfig from "#private/lib/config"; +import HttpCode from "@server/types/HttpCode"; + +export function requireOrgIdentityProviderMode( + _req: Request, + _res: Response, + next: NextFunction +): void { + if (privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + + return next(); +} diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts new file mode 100644 index 000000000..f6ab557b3 --- /dev/null +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -0,0 +1,96 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idpOrg } 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, sql } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + idpId: z.coerce.number().int().positive() + }) + .strict(); + +export async function unassociateOrgIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, idpId } = parsedParams.data; + + const [association] = await db + .select() + .from(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!association) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `IdP with ID ${idpId} is not associated with organization ${orgId}` + ) + ); + } + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + if (count <= 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This is the last organization associated with this identity provider. Delete it instead." + ) + ); + } + + await db + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Org IdP unassociated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index 17bf2ee35..7c379f8ec 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -26,7 +26,6 @@ import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z @@ -48,6 +47,7 @@ const bodySchema = z.strictObject({ scopes: z.string().optional(), autoProvision: z.boolean().optional(), roleMapping: z.string().optional(), + orgMapping: z.string().nullish(), tags: z.string().optional() }); @@ -99,18 +99,6 @@ export async function updateOrgOidcIdp( ); } - if ( - privateConfig.getRawPrivateConfig().app.identity_provider_mode !== - "org" - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." - ) - ); - } - const { idpId, orgId } = parsedParams.data; const { clientId, @@ -123,6 +111,7 @@ export async function updateOrgOidcIdp( namePath, name, roleMapping, + orgMapping, tags } = parsedBody.data; @@ -218,13 +207,20 @@ export async function updateOrgOidcIdp( .where(eq(idpOidcConfig.idpId, idpId)); } + const idpOrgPolicyPatch: { + roleMapping?: string; + orgMapping?: string | null; + } = {}; if (roleMapping !== undefined) { - // Update IdP-org policy + idpOrgPolicyPatch.roleMapping = roleMapping; + } + if (orgMapping !== undefined) { + idpOrgPolicyPatch.orgMapping = orgMapping; + } + if (Object.keys(idpOrgPolicyPatch).length > 0) { await trx .update(idpOrg) - .set({ - roleMapping - }) + .set(idpOrgPolicyPatch) .where( and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)) ); diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index 085acf0c6..94dddb1cf 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -103,7 +103,8 @@ export async function listDomains( const [{ count }] = await db .select({ count: sql`count(*)` }) - .from(domains); + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)); return response(res, { data: { diff --git a/server/routers/healthChecks/types.ts b/server/routers/healthChecks/types.ts index d8395c593..0def60833 100644 --- a/server/routers/healthChecks/types.ts +++ b/server/routers/healthChecks/types.ts @@ -2,6 +2,9 @@ export type ListHealthChecksResponse = { healthChecks: { targetHealthCheckId: number; name: string; + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; hcEnabled: boolean; hcHealth: "unknown" | "healthy" | "unhealthy"; hcMode: string | null; diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 46729f11d..f87d38450 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -86,7 +86,8 @@ export async function buildClientConfigurationForNewtClient( // ) // ); - if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm + if (!client.clientSitesAssociationsCache.isJitMode) { + // if we are adding sites through jit then dont add the site to the olm // update the peer info on the olm // if the peer has not been added yet this will be a no-op await updatePeer(client.clients.clientId, { @@ -189,7 +190,10 @@ export async function buildClientConfigurationForNewtClient( }; } -export async function buildTargetConfigurationForNewtClient(siteId: number) { +export async function buildTargetConfigurationForNewtClient( + siteId: number, + version?: string | null +) { // Get all enabled targets with their resource protocol information const allTargets = await db .select({ @@ -200,8 +204,15 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, - protocol: resources.protocol, - hcId: targetHealthCheck.targetHealthCheckId, + protocol: resources.protocol + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + + const allHealthChecks = await db + .select({ + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, @@ -219,13 +230,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold }) - .from(targets) - .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + .from(targetHealthCheck) + .where(eq(targetHealthCheck.siteId, siteId)); const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { @@ -249,7 +255,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { { tcpTargets: [] as string[], udpTargets: [] as string[] } ); - const healthCheckTargets = allTargets.map((target) => { + const healthCheckTargets = allHealthChecks.map((target) => { // make sure the stuff is defined const isTCP = target.hcMode?.toLowerCase() === "tcp"; if (!target.hcHostname || !target.hcPort || !target.hcInterval) { @@ -273,8 +279,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) { } return { - id: target.targetId, - hcId: target.hcId, + id: target.targetHealthCheckId, hcEnabled: target.hcEnabled, hcPath: target.hcPath, hcScheme: target.hcScheme, diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index fce42caa3..f3902a35d 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { } const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(siteId); + await buildTargetConfigurationForNewtClient(siteId, newtVersion); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 572c63e98..25b520854 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -83,8 +83,7 @@ export async function addTargets( } return { - id: target.targetId, - hcId: hc.targetHealthCheckId, + id: hc.targetHealthCheckId, hcEnabled: hc.hcEnabled, hcPath: hc.hcPath, hcScheme: hc.hcScheme, @@ -121,6 +120,96 @@ export async function addTargets( ); } +export async function addStandaloneHealthCheck( + newtId: string, + healthCheck: TargetHealthCheck, + version?: string | null +) { + const isTCP = healthCheck.hcMode?.toLowerCase() === "tcp"; + if ( + !healthCheck.hcHostname || + !healthCheck.hcPort || + !healthCheck.hcInterval + ) { + logger.debug( + `Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing fields` + ); + return; + } + if (!isTCP && (!healthCheck.hcPath || !healthCheck.hcMethod)) { + logger.debug( + `Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing HTTP health check fields` + ); + return; + } + + const hcHeadersParse = healthCheck.hcHeaders + ? JSON.parse(healthCheck.hcHeaders) + : null; + const hcHeadersSend: { [key: string]: string } = {}; + if (hcHeadersParse) { + hcHeadersParse.forEach((header: { name: string; value: string }) => { + hcHeadersSend[header.name] = header.value; + }); + } + + let hcStatus: number | undefined = undefined; + if (healthCheck.hcStatus) { + const parsedStatus = parseInt(healthCheck.hcStatus.toString()); + if (!isNaN(parsedStatus)) { + hcStatus = parsedStatus; + } + } + + await sendToClient( + newtId, + { + type: `newt/healthcheck/add`, + data: { + targets: [ + { + id: healthCheck.targetHealthCheckId, + hcEnabled: healthCheck.hcEnabled, + hcPath: healthCheck.hcPath, + hcScheme: healthCheck.hcScheme, + hcMode: healthCheck.hcMode, + hcHostname: healthCheck.hcHostname, + hcPort: healthCheck.hcPort, + hcInterval: healthCheck.hcInterval, + hcUnhealthyInterval: healthCheck.hcUnhealthyInterval, + hcTimeout: healthCheck.hcTimeout, + hcHeaders: hcHeadersSend, + hcFollowRedirects: healthCheck.hcFollowRedirects, + hcMethod: healthCheck.hcMethod, + hcStatus: hcStatus, + hcTlsServerName: healthCheck.hcTlsServerName, + hcHealthyThreshold: healthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: healthCheck.hcUnhealthyThreshold + } + ] + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} + +export async function removeStandaloneHealthCheck( + newtId: string, + healthCheckId: number, + version?: string | null +) { + await sendToClient( + newtId, + { + type: `newt/healthcheck/remove`, + data: { + ids: [healthCheckId] + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); +} + export async function removeTargets( newtId: string, targets: Target[], diff --git a/server/routers/orgIdp/types.ts b/server/routers/orgIdp/types.ts index f6f581eed..40dbb2cf4 100644 --- a/server/routers/orgIdp/types.ts +++ b/server/routers/orgIdp/types.ts @@ -25,3 +25,22 @@ export type ListOrgIdpsResponse = { offset: number; }; }; + +export type ListUserAdminOrgIdpsEntry = { + idpId: number; + orgId: string; + orgName: string; + name: string; + type: string; + variant: string; + tags: string | null; +}; + +export type ListUserAdminOrgIdpsResponse = { + idps: ListUserAdminOrgIdpsEntry[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index a31e5179e..ea7512b9c 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -230,6 +230,8 @@ export async function createTarget( .values({ orgId: resource.orgId, targetId: newTarget[0].targetId, + siteId: targetData.siteId, + name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, hcScheme: targetData.hcScheme ?? null, diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index a049e3224..55834d926 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -99,19 +99,19 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( name: targetHealthCheck.name, hcStatus: targetHealthCheck.hcHealth }) - .from(targets) + .from(targetHealthCheck) + .innerJoin( + targets, + eq(targetHealthCheck.targetId, targets.targetId) + ) .innerJoin( resources, eq(targets.resourceId, resources.resourceId) ) .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .innerJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) .where( and( - eq(targets.targetId, targetIdNum), + eq(targetHealthCheck.targetHealthCheckId, targetIdNum), eq(sites.siteId, newt.siteId) ) ) @@ -142,13 +142,21 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( | "healthy" | "unhealthy" }) - .where(eq(targetHealthCheck.targetId, targetIdNum)); + .where(eq(targetHealthCheck.targetId, targetCheck.targetId)); + + const orgId = targetCheck.orgId || targetCheck.resourceOrgId; // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + if (!orgId) { + logger.warn( + `No org ID found for target ${targetId}, skipping status history logging` + ); + continue; + } // Log the state change to status history await db.insert(statusHistory).values({ entityType: "healthCheck", entityId: targetCheck.targetHealthCheckId, - orgId: targetCheck.orgId || targetCheck.resourceOrgId, + orgId: orgId, status: healthStatus.status, timestamp: Math.floor(Date.now() / 1000) }); @@ -170,7 +178,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .where( and( eq(targets.resourceId, targetCheck.resourceId), - eq(targets.targetId, targetIdNum) // only check the other targets, not the one we just updated + eq(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated ) ); @@ -188,7 +196,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( await db.insert(statusHistory).values({ entityType: "resource", entityId: targetCheck.resourceId, - orgId: targetCheck.orgId || targetCheck.resourceOrgId, + orgId: orgId, status: status, timestamp: Math.floor(Date.now() / 1000) }); @@ -197,13 +205,13 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( // because we are checking above if there was a change we can fire the alert here because it changed if (healthStatus.status === "unhealthy") { await fireHealthCheckHealthyAlert( - targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + orgId, targetCheck.targetHealthCheckId, targetCheck.name ); } else if (healthStatus.status === "healthy") { await fireHealthCheckNotHealthyAlert( - targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId + orgId, targetCheck.targetHealthCheckId, targetCheck.name ); diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index e42ce98a1..52759bfc8 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -228,6 +228,7 @@ export async function updateTarget( const [updatedHc] = await db .update(targetHealthCheck) .set({ + siteId: parsedBody.data.siteId, hcEnabled: parsedBody.data.hcEnabled || false, hcPath: parsedBody.data.hcPath, hcScheme: parsedBody.data.hcScheme, diff --git a/server/setup/ensureRootApiKey.ts b/server/setup/ensureRootApiKey.ts new file mode 100644 index 000000000..55f5186b3 --- /dev/null +++ b/server/setup/ensureRootApiKey.ts @@ -0,0 +1,106 @@ +import { db, apiKeys } from "@server/db"; +import { eq } from "drizzle-orm"; +import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; +import moment from "moment"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; + +const random: RandomReader = { + read(bytes: Uint8Array): void { + crypto.getRandomValues(bytes); + } +}; + +function validateApiKeyId(id: string): boolean { + return /^[a-z0-9]{15}$/.test(id); +} + +function validateApiKeySecret(secret: string): boolean { + return secret.length > 0; +} + +function showRootApiKey(apiKeyId: string, source: string): void { + console.log(`=== ROOT API KEY ${source} ===`); + console.log("API Key ID:", apiKeyId); + console.log( + "The root API key from PANGOLIN_ROOT_API_KEY has been applied." + ); + console.log("Use the full key value (apiKeyId.apiKeySecret) in requests."); + console.log("================================"); +} + +export async function ensureRootApiKey() { + try { + const envApiKey = process.env.PANGOLIN_ROOT_API_KEY; + + if (!envApiKey) { + // logger.debug( + // "PANGOLIN_ROOT_API_KEY not set. Root API key from environment skipped." + // ); + return; + } + + const parts = envApiKey.split("."); + if (parts.length !== 2) { + throw new Error( + "Invalid format for PANGOLIN_ROOT_API_KEY. Expected format: {apiKeyId}.{apiKeySecret}" + ); + } + + const [apiKeyId, apiKeySecret] = parts; + + if (!validateApiKeyId(apiKeyId)) { + throw new Error( + "Invalid apiKeyId in PANGOLIN_ROOT_API_KEY. Must be 15 lowercase alphanumeric characters." + ); + } + + if (!validateApiKeySecret(apiKeySecret)) { + throw new Error( + "Invalid apiKeySecret in PANGOLIN_ROOT_API_KEY. Secret must not be empty." + ); + } + + const apiKeyHash = await hashPassword(apiKeySecret); + const lastChars = apiKeySecret.slice(-4); + const createdAt = moment().toISOString(); + + const [existingKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)); + + if (existingKey) { + if (!existingKey.isRoot) { + console.warn( + `API key with ID ${apiKeyId} exists but is not a root key. Promoting to root and updating hash.` + ); + } else { + console.warn( + `Overwriting existing root API key hash since PANGOLIN_ROOT_API_KEY is set (apiKeyId: ${apiKeyId})` + ); + } + + await db + .update(apiKeys) + .set({ apiKeyHash, lastChars, isRoot: true }) + .where(eq(apiKeys.apiKeyId, apiKeyId)); + + showRootApiKey(apiKeyId, "UPDATED FROM ENVIRONMENT"); + } else { + await db.insert(apiKeys).values({ + apiKeyId, + name: "Root API Key (Environment)", + apiKeyHash, + lastChars, + createdAt, + isRoot: true + }); + + showRootApiKey(apiKeyId, "CREATED FROM ENVIRONMENT"); + } + } catch (error) { + console.error("Failed to ensure root API key:", error); + throw error; + } +} diff --git a/server/setup/index.ts b/server/setup/index.ts index 2dfb633e5..c46e6b8fd 100644 --- a/server/setup/index.ts +++ b/server/setup/index.ts @@ -2,10 +2,12 @@ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; import { clearStaleData } from "./clearStaleData"; import { ensureSetupToken } from "./ensureSetupToken"; +import { ensureRootApiKey } from "./ensureRootApiKey"; export async function runSetupFunctions() { await copyInConfig(); // copy in the config to the db as needed await ensureActions(); // make sure all of the actions are in the db and the roles await clearStaleData(); await ensureSetupToken(); // ensure setup token exists for initial setup + await ensureRootApiKey(); } diff --git a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx index de69de046..7f7060b05 100644 --- a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx +++ b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx @@ -12,6 +12,11 @@ import type { ListRolesResponse } from "@server/routers/role"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Approvals" +}; export interface ApprovalFeedPageProps { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/(private)/billing/layout.tsx b/src/app/[orgId]/settings/(private)/billing/layout.tsx index 69c3da485..2bb88963d 100644 --- a/src/app/[orgId]/settings/(private)/billing/layout.tsx +++ b/src/app/[orgId]/settings/(private)/billing/layout.tsx @@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { build } from "@server/build"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Billing" +}; type BillingSettingsProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 37334e342..90b89f76f 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -97,7 +97,8 @@ export default function GeneralPage() { emailPath: z.string().nullable().optional(), namePath: z.string().nullable().optional(), scopes: z.string().min(1, { message: t("idpScopeRequired") }), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); // Google form schema (simplified) @@ -109,7 +110,8 @@ export default function GeneralPage() { .min(1, { message: t("idpClientSecretRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); // Azure form schema (simplified with tenant ID) @@ -122,7 +124,8 @@ export default function GeneralPage() { tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), - autoProvision: z.boolean().default(false) + autoProvision: z.boolean().default(false), + orgMapping: z.string().optional() }); type OidcFormValues = z.infer; @@ -160,7 +163,8 @@ export default function GeneralPage() { autoProvision: true, roleMapping: null, roleId: null, - tenantId: "" + tenantId: "", + orgMapping: "" } }); @@ -227,7 +231,8 @@ export default function GeneralPage() { clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, - roleId: null + roleId: null, + orgMapping: data.idpOrg?.orgMapping ?? "" }; // Add variant-specific fields @@ -344,12 +349,14 @@ export default function GeneralPage() { } // Build payload based on variant + const orgMappingTrimmed = data.orgMapping?.trim() ?? ""; let payload: any = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, - roleMapping: roleMappingExpression + roleMapping: roleMappingExpression, + orgMapping: orgMappingTrimmed === "" ? null : orgMappingTrimmed }; // Add variant-specific fields @@ -532,6 +539,10 @@ export default function GeneralPage() { } rawExpression={rawRoleExpression} onRawExpressionChange={setRawRoleExpression} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} /> diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx index 6cdbf23c0..2d57d878b 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx @@ -6,6 +6,11 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Identity Provider" +}; interface SettingsLayoutProps { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx index ecc2aa835..a9c69d6bb 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Identity Provider" +}; + export default async function IdpPage(props: { params: Promise<{ orgId: string; idpId: string }>; }) { diff --git a/src/app/[orgId]/settings/(private)/idp/create/layout.tsx b/src/app/[orgId]/settings/(private)/idp/create/layout.tsx new file mode 100644 index 000000000..8f606fca1 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Identity Provider" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 10d86b976..a7796e2a9 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -91,7 +91,8 @@ export default function Page() { tenantId: z.string().optional(), autoProvision: z.boolean().default(false), roleMapping: z.string().nullable().optional(), - roleId: z.number().nullable().optional() + roleId: z.number().nullable().optional(), + orgMapping: z.string().optional() }); type CreateIdpFormValues = z.infer; @@ -112,7 +113,8 @@ export default function Page() { tenantId: "", autoProvision: false, roleMapping: null, - roleId: null + roleId: null, + orgMapping: "" } }); @@ -177,7 +179,7 @@ export default function Page() { return; } - const payload = { + const payload: Record = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, @@ -191,6 +193,10 @@ export default function Page() { scopes: data.scopes, variant: data.type }; + const trimmedOrgMapping = data.orgMapping?.trim(); + if (trimmedOrgMapping) { + payload.orgMapping = trimmedOrgMapping; + } // Use the appropriate endpoint based on provider type const endpoint = "oidc"; @@ -336,6 +342,10 @@ export default function Page() { } rawExpression={rawRoleExpression} onRawExpressionChange={setRawRoleExpression} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} /> diff --git a/src/app/[orgId]/settings/(private)/idp/page.tsx b/src/app/[orgId]/settings/(private)/idp/page.tsx index cd0bc5566..27d636fa5 100644 --- a/src/app/[orgId]/settings/(private)/idp/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/page.tsx @@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Identity Providers" +}; type OrgIdpPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/(private)/license/page.tsx b/src/app/[orgId]/settings/(private)/license/page.tsx index 1ecc94c19..6327689b3 100644 --- a/src/app/[orgId]/settings/(private)/license/page.tsx +++ b/src/app/[orgId]/settings/(private)/license/page.tsx @@ -3,6 +3,11 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Enterprise Licenses" +}; type Props = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx index 5b9fd628d..a368ec687 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Remote Exit Node" +}; + export default async function RemoteExitNodePage(props: { params: Promise<{ orgId: string; remoteExitNodeId: string }>; }) { diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/layout.tsx new file mode 100644 index 000000000..e0c382654 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Remote Exit Node" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx index 2da0e0da5..2c34d92ec 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx @@ -7,6 +7,11 @@ import ExitNodesTable, { } from "@app/components/ExitNodesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Remote Exit Nodes" +}; type RemoteExitNodesPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index ae37c3752..84a864ba8 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -11,6 +11,11 @@ import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Invitations" +}; type InvitationsPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/access/page.tsx b/src/app/[orgId]/settings/access/page.tsx index 229ffffbc..f6df6ed3a 100644 --- a/src/app/[orgId]/settings/access/page.tsx +++ b/src/app/[orgId]/settings/access/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Access" +}; + type AccessPageProps = { params: Promise<{ orgId: string }>; }; diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index 7165d9e6c..c1ecb2b12 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -8,6 +8,11 @@ import RolesTable, { type RoleRow } from "@app/components/RolesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Roles" +}; type RolesPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 7d527f84e..0a9815c36 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -8,6 +8,11 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { cache } from "react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "User" +}; interface UserLayoutProps { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/access/users/[userId]/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/page.tsx index 041537286..c56533dad 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "User" +}; + export default async function UserPage(props: { params: Promise<{ orgId: string; userId: string }>; }) { diff --git a/src/app/[orgId]/settings/access/users/create/layout.tsx b/src/app/[orgId]/settings/access/users/create/layout.tsx new file mode 100644 index 000000000..2796ddbc0 --- /dev/null +++ b/src/app/[orgId]/settings/access/users/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create User" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 04d347698..2c3292f9e 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -46,7 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import OrgRolesTagField from "@app/components/OrgRolesTagField"; @@ -152,31 +152,8 @@ export default function Page() { const getIdpIcon = (variant: string | null) => { if (!variant) return null; - - switch (variant.toLowerCase()) { - case "google": - return ( - {t("idpGoogleAlt")} - ); - case "azure": - return ( - {t("idpAzureAlt")} - ); - default: - return null; - } + const type = variant.toLowerCase(); + return ; }; const validFor = [ @@ -490,7 +467,7 @@ export default function Page() {
- {!inviteLink ? ( + {!inviteLink && userOptions.length > 1 ? ( @@ -513,7 +490,7 @@ export default function Page() { genericOidcForm.reset(); } }} - cols={2} + cols={3} /> diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 84685cc04..23c1d69c6 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -11,6 +11,11 @@ import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Users" +}; type UsersPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index 34afceaab..50c612bbf 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -49,7 +49,6 @@ export default function EditAlertRulePage() { }); setFormValues(null); }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [orgId, alertRuleId]); useEffect(() => { diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx index 19b695ca2..300058432 100644 --- a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx @@ -7,6 +7,11 @@ import { GetApiKeyResponse } from "@server/routers/apiKeys"; import ApiKeyProvider from "@app/providers/ApiKeyProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "API Key" +}; interface SettingsLayoutProps { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx index 518db250b..63516208d 100644 --- a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "API Key" +}; + export default async function ApiKeysPage(props: { params: Promise<{ orgId: string; apiKeyId: string }>; }) { diff --git a/src/app/[orgId]/settings/api-keys/create/layout.tsx b/src/app/[orgId]/settings/api-keys/create/layout.tsx new file mode 100644 index 000000000..22e868c85 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create API Key" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/api-keys/page.tsx b/src/app/[orgId]/settings/api-keys/page.tsx index 0ed9553af..d06e0983b 100644 --- a/src/app/[orgId]/settings/api-keys/page.tsx +++ b/src/app/[orgId]/settings/api-keys/page.tsx @@ -2,11 +2,14 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import OrgApiKeysTable, { - OrgApiKeyRow -} from "@app/components/OrgApiKeysTable"; +import OrgApiKeysTable, { OrgApiKeyRow } from "@app/components/OrgApiKeysTable"; import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "API Keys" +}; type ApiKeyPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx index fe3ae4b9f..102b7b781 100644 --- a/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx +++ b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx @@ -17,7 +17,7 @@ type BluePrintsPageProps = { }; export const metadata: Metadata = { - title: "Blueprint Detail" + title: "Edit Blueprint" }; export default async function BluePrintDetailPage(props: BluePrintsPageProps) { diff --git a/src/app/[orgId]/settings/blueprints/create/page.tsx b/src/app/[orgId]/settings/blueprints/create/page.tsx index e7a0490e2..17fe60bf2 100644 --- a/src/app/[orgId]/settings/blueprints/create/page.tsx +++ b/src/app/[orgId]/settings/blueprints/create/page.tsx @@ -12,7 +12,7 @@ export interface CreateBlueprintPageProps { } export const metadata: Metadata = { - title: "Create blueprint" + title: "Create Blueprint" }; export default async function CreateBlueprintPage( diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx index 145fb1728..9d13e6ba4 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx @@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Machine Client" +}; type SettingsLayoutProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx index 3aa4a2c4a..50594c62e 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Machine Client" +}; + export default async function ClientPage(props: { params: Promise<{ orgId: string; niceId: number | string }>; }) { diff --git a/src/app/[orgId]/settings/clients/machine/create/layout.tsx b/src/app/[orgId]/settings/clients/machine/create/layout.tsx new file mode 100644 index 000000000..945b20a99 --- /dev/null +++ b/src/app/[orgId]/settings/clients/machine/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Machine Client" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index 4b40c906c..fe9281ac7 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -8,6 +8,11 @@ import { ListClientsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Pagination } from "@server/types/Pagination"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Machine Clients" +}; type ClientsPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index aeea1c83f..dcb8a2b84 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Clients" +}; + type ClientsPageProps = { params: Promise<{ orgId: string }>; searchParams: Promise<{ view?: string }>; diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx index 2d9934cbe..9d3b169d8 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx @@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "User Device" +}; type SettingsLayoutProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx index 9ad97186d..a2c798c1c 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx @@ -1,10 +1,13 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "User Device" +}; + export default async function ClientPage(props: { params: Promise<{ orgId: string; niceId: number | string }>; }) { const params = await props.params; - redirect( - `/${params.orgId}/settings/clients/user/${params.niceId}/general` - ); + redirect(`/${params.orgId}/settings/clients/user/${params.niceId}/general`); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index fcb24e4e3..23fba583a 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -7,6 +7,11 @@ import { type ListUserDevicesResponse } from "@server/routers/client"; import type { Pagination } from "@server/types/Pagination"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "User Devices" +}; type ClientsPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 23a79737d..9f9878967 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,16 +1,13 @@ -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import DomainInfoCard from "@app/components/DomainInfoCard"; -import RestartDomainButton from "@app/components/RestartDomainButton"; +import DomainPageClient from "@app/components/DomainPageClient"; import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { pullEnv } from "@app/lib/pullEnv"; -import { getTranslations } from "next-intl/server"; -import RefreshButton from "@app/components/RefreshButton"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { GetDNSRecordsResponse } from "@server/routers/domain"; -import DNSRecordsTable from "@app/components/DNSRecordTable"; -import DomainCertForm from "@app/components/DomainCertForm"; -import { build } from "@server/build"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Domain" +}; interface DomainSettingsPageProps { params: Promise<{ domainId: string; orgId: string }>; @@ -20,8 +17,6 @@ export default async function DomainSettingsPage({ params }: DomainSettingsPageProps) { const { domainId, orgId } = await params; - const t = await getTranslations(); - const env = pullEnv(); let domain: GetDomainResponse | null = null; try { @@ -34,57 +29,27 @@ export default async function DomainSettingsPage({ return null; } - let dnsRecords; + let dnsRecords: GetDNSRecordsResponse | null = null; try { const response = await internal.get( `/org/${orgId}/domain/${domainId}/dns-records`, await authCookieHeader() ); dnsRecords = response.data.data; - } catch (error) { + } catch { return null; } - if (!domain) { + if (!domain || !dnsRecords) { return null; } return ( - <> -
- - {env.flags.usePangolinDns && domain.failed ? ( - - ) : ( - - )} -
-
- {build != "oss" && env.flags.usePangolinDns ? ( - - ) : null} - - - - {domain.type == "wildcard" && !domain.configManaged && ( - - )} -
- + ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index d1325d32b..affad2551 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -11,6 +11,11 @@ import OrgProvider from "@app/providers/OrgProvider"; import { ListDomainsResponse } from "@server/routers/domain"; import { toUnicode } from "punycode"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Domains" +}; type Props = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx index 0bd482864..7712334f1 100644 --- a/src/app/[orgId]/settings/general/auth-page/page.tsx +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -11,6 +11,7 @@ import { GetLoginPageResponse } from "@server/routers/loginPage/types"; import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; export interface AuthPageProps { diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 736e2037e..8620cd529 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -11,6 +11,11 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { build } from "@server/build"; import { pullEnv } from "@app/lib/pullEnv"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Organization" +}; type GeneralSettingsProps = { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/logs/access/layout.tsx b/src/app/[orgId]/settings/logs/access/layout.tsx new file mode 100644 index 000000000..07d7f6f28 --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Access Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/action/layout.tsx b/src/app/[orgId]/settings/logs/action/layout.tsx new file mode 100644 index 000000000..889617712 --- /dev/null +++ b/src/app/[orgId]/settings/logs/action/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Action Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/analytics/page.tsx b/src/app/[orgId]/settings/logs/analytics/page.tsx index f5bd4e7aa..9246c3cbb 100644 --- a/src/app/[orgId]/settings/logs/analytics/page.tsx +++ b/src/app/[orgId]/settings/logs/analytics/page.tsx @@ -2,6 +2,11 @@ import { LogAnalyticsData } from "@app/components/LogAnalyticsData"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Log Analytics" +}; export interface AnalyticsPageProps { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/logs/connection/layout.tsx b/src/app/[orgId]/settings/logs/connection/layout.tsx new file mode 100644 index 000000000..20d93f802 --- /dev/null +++ b/src/app/[orgId]/settings/logs/connection/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Connection Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx index d9663e721..7c2a6532b 100644 --- a/src/app/[orgId]/settings/logs/page.tsx +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -1,3 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Logs" +}; + export default function GeneralPage() { return null; } diff --git a/src/app/[orgId]/settings/logs/request/layout.tsx b/src/app/[orgId]/settings/logs/request/layout.tsx new file mode 100644 index 000000000..61c3a3a7d --- /dev/null +++ b/src/app/[orgId]/settings/logs/request/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Request Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/logs/streaming/layout.tsx b/src/app/[orgId]/settings/logs/streaming/layout.tsx new file mode 100644 index 000000000..a5baea411 --- /dev/null +++ b/src/app/[orgId]/settings/logs/streaming/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Streaming Logs" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/page.tsx b/src/app/[orgId]/settings/page.tsx index 9956bc859..bf8beab72 100644 --- a/src/app/[orgId]/settings/page.tsx +++ b/src/app/[orgId]/settings/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Settings" +}; + type OrgPageProps = { params: Promise<{ orgId: string }>; }; diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx index 32a06706d..fc95a655d 100644 --- a/src/app/[orgId]/settings/provisioning/keys/page.tsx +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -12,6 +12,11 @@ import DismissableBanner from "@app/components/DismissableBanner"; import Link from "next/link"; import { Button } from "@app/components/ui/button"; import { ArrowRight, Plug } from "lucide-react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Provisioning Keys" +}; type ProvisioningKeysPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx index 51db66c2d..1e0377590 100644 --- a/src/app/[orgId]/settings/provisioning/page.tsx +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Provisioning" +}; + type ProvisioningPageProps = { params: Promise<{ orgId: string }>; }; @@ -7,4 +12,4 @@ type ProvisioningPageProps = { export default async function ProvisioningPage(props: ProvisioningPageProps) { const params = await props.params; redirect(`/${params.orgId}/settings/provisioning/keys`); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index 4669f9160..ee7246821 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -11,6 +11,11 @@ import { Button } from "@app/components/ui/button"; import { ArrowRight, Plug } from "lucide-react"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Pending Sites" +}; type PendingSitesPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index c15e3d429..da967feea 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -10,8 +10,13 @@ import type { ListResourcesResponse } from "@server/routers/resource"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Private Resources" +}; + export interface ClientResourcesPageProps { params: Promise<{ orgId: string }>; searchParams: Promise>; diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 954b966ac..55ebe6554 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Public Resources" +}; + export interface ResourcesPageProps { params: Promise<{ orgId: string }>; } diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 5f47e1938..f7de28c56 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -62,7 +62,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import UptimeBar from "@app/components/UptimeBar"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; type MaintenanceSectionFormProps = { resource: GetResourceResponse; @@ -579,19 +579,13 @@ export default function GeneralForm() { return ( <> - - - Uptime - - Site availability over the last 90 days. - - - - {resource?.resourceId && ( - - )} - - + {resource?.resourceId && resource?.orgId && ( + + )} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx index f410b4c8b..2f6cd1492 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx @@ -14,6 +14,11 @@ import OrgProvider from "@app/providers/OrgProvider"; import { cache } from "react"; import ResourceInfoBox from "@app/components/ResourceInfoBox"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Public Resource" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx index 5ec1cf00d..06a4af045 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Public Resource" +}; + export default async function ResourcePage(props: { params: Promise<{ niceId: string; orgId: string }>; }) { diff --git a/src/app/[orgId]/settings/resources/proxy/create/layout.tsx b/src/app/[orgId]/settings/resources/proxy/create/layout.tsx new file mode 100644 index 000000000..7e635a730 --- /dev/null +++ b/src/app/[orgId]/settings/resources/proxy/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Public Resource" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 535b597f2..1c9e5b1bb 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -776,9 +776,15 @@ export default function Page() { pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -804,9 +810,15 @@ export default function Page() { pathMatchType: row.original.pathMatchType }} onChange={(config) => - updateTarget(row.original.targetId, - config.path === null && config.pathMatchType === null - ? { ...config, rewritePath: null, rewritePathType: null } + updateTarget( + row.original.targetId, + config.path === null && + config.pathMatchType === null + ? { + ...config, + rewritePath: null, + rewritePathType: null + } : config ) } @@ -1061,7 +1073,7 @@ export default function Page() { : null ); }} - cols={2} + cols={3} /> )} @@ -1118,28 +1130,30 @@ export default function Page() { - = 1 - } - onDomainChange={(res) => { - if (!res) return; + + = 1 + } + onDomainChange={(res) => { + if (!res) return; - httpForm.setValue( - "subdomain", - res.subdomain - ); - httpForm.setValue( - "domainId", - res.domainId - ); - console.log( - "Domain changed:", - res - ); - }} - /> + httpForm.setValue( + "subdomain", + res.subdomain + ); + httpForm.setValue( + "domainId", + res.domainId + ); + console.log( + "Domain changed:", + res + ); + }} + /> + ) : ( @@ -1155,98 +1169,101 @@ export default function Page() { -
- { - if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh - } - }} - className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" - id="tcp-udp-settings-form" - > - ( - - - {t("protocol")} - - - - - )} - /> + + + { + if (e.key === "Enter") { + e.preventDefault(); // block default enter refresh + } + }} + className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" + id="tcp-udp-settings-form" + > + ( + + + {t( + "protocol" + )} + + + + + )} + /> - ( - - - {t( - "resourcePortNumber" - )} - - - - field.onChange( + ( + + + {t( + "resourcePortNumber" + )} + + + - - - - {t( - "resourcePortNumberDescription" - )} - - - )} - /> - - + ) => + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + )} + /> + + +
)} diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 05425b4bd..cdbf959f4 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -13,6 +13,11 @@ import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; import { toUnicode } from "punycode"; import { cache } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Public Resources" +}; export interface ProxyResourcesPageProps { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/share-links/page.tsx b/src/app/[orgId]/settings/share-links/page.tsx index b41a3d1ce..1a732f714 100644 --- a/src/app/[orgId]/settings/share-links/page.tsx +++ b/src/app/[orgId]/settings/share-links/page.tsx @@ -7,10 +7,13 @@ import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { ListAccessTokensResponse } from "@server/routers/accessToken"; -import ShareLinksTable, { - ShareLinkRow -} from "@app/components/ShareLinksTable"; +import ShareLinksTable, { ShareLinkRow } from "@app/components/ShareLinksTable"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Shareable Links" +}; type ShareLinksPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 3527e41cb..f4c4d72ef 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -1,6 +1,6 @@ "use client"; -import UptimeBar from "@app/components/UptimeBar"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -113,19 +113,13 @@ export default function GeneralPage() { return ( - - - Uptime - - Site availability over the last 90 days. - - - - {site?.siteId && ( - - )} - - + {site?.siteId && site?.orgId && ( + + )} diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index aa02bb667..d5e11e9bc 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -8,7 +8,11 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SiteInfoCard from "@app/components/SiteInfoCard"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; +export const metadata: Metadata = { + title: "Site" +}; interface SettingsLayoutProps { children: React.ReactNode; diff --git a/src/app/[orgId]/settings/sites/[niceId]/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/page.tsx index 045b762e3..8f505e85c 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/page.tsx @@ -1,5 +1,10 @@ +import type { Metadata } from "next"; import { redirect } from "next/navigation"; +export const metadata: Metadata = { + title: "Site" +}; + export default async function SitePage(props: { params: Promise<{ orgId: string; niceId: string }>; }) { diff --git a/src/app/[orgId]/settings/sites/create/layout.tsx b/src/app/[orgId]/settings/sites/create/layout.tsx new file mode 100644 index 000000000..fc8f1edf2 --- /dev/null +++ b/src/app/[orgId]/settings/sites/create/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Create Site" +}; + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 38083325b..d78666d78 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -5,8 +5,13 @@ import { AxiosResponse } from "axios"; import SitesTable, { SiteRow } from "@app/components/SitesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SitesBanner from "@app/components/SitesBanner"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; +export const metadata: Metadata = { + title: "Sites" +}; + type SitesPageProps = { params: Promise<{ orgId: string }>; searchParams: Promise>; diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index 60e8a094a..e9438da33 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -20,7 +20,6 @@ import { import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -63,7 +62,7 @@ import { SettingsSectionForm } from "@app/components/Settings"; import { useTranslations } from "next-intl"; -import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import { compileRoleMappingExpression, createMappingBuilderRule, @@ -499,9 +498,17 @@ export default function PoliciesPage() { id="policy-default-mappings-form" className="space-y-6" > - {}} + orgMappingField={{ + control: defaultMappingsForm.control, + name: "defaultOrgMapping", + labelKey: "defaultMappingsOrg" + }} + roleMappingFieldIdPrefix="admin-idp-default-role" + showFreeformRoleNamesHint roleMappingMode={defaultRoleMappingMode} onRoleMappingModeChange={ setDefaultRoleMappingMode @@ -528,27 +535,6 @@ export default function PoliciesPage() { setDefaultRawRoleExpression } /> - - ( - - - {t("defaultMappingsOrg")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> @@ -687,9 +673,15 @@ export default function PoliciesPage() { )} /> - {}} + orgMappingField={{ + control: form.control, + name: "orgMapping" + }} + roleMappingFieldIdPrefix="admin-idp-policy-role" roleMappingMode={policyRoleMappingMode} onRoleMappingModeChange={ setPolicyRoleMappingMode @@ -716,27 +708,6 @@ export default function PoliciesPage() { setPolicyRawRoleExpression } /> - - ( - - - {t("orgMappingPathOptional")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 82036c510..6e3270a55 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -24,7 +24,6 @@ import { import HeaderTitle from "@app/components/SettingsSectionTitle"; import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; @@ -34,7 +33,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -220,23 +218,6 @@ export default function Page() { )} /> -
- { - form.setValue( - "autoProvision", - checked - ); - }} - /> -
@@ -244,6 +225,32 @@ export default function Page() {
+ + + + {t("idpAutoProvisionUsers")} + + + + + + +
+ { + form.setValue("autoProvision", checked); + }} + /> +

+ {t("idpAutoProvisionConfigureAfterCreate")} +

+
+
+
+
- - - + + + )} diff --git a/src/app/globals.css b/src/app/globals.css index bbb165c28..aa98b1d49 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,7 +6,7 @@ :root { --radius: 0.75rem; - --background: oklch(0.985 0 0); + --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); --card-foreground: oklch(0.141 0.005 285.823); @@ -22,30 +22,30 @@ --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.91 0.004 286.32); - --input: oklch(0.92 0.004 286.32); + --border: oklch(0.88 0.004 286.32); + --input: oklch(0.88 0.004 286.32); --ring: oklch(0.705 0.213 47.604); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); + --sidebar: #fafafa; --sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-primary: oklch(0.705 0.213 47.604); --sidebar-primary-foreground: oklch(0.98 0.016 73.684); - --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent: #eaeaea; --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.213 47.604); } .dark { - --background: oklch(0.19 0.006 285.885); + --background: #0d0d0f; --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); + --card: #0d0d0f; --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0.006 285.885); + --popover: #0d0d0f; --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.6717 0.1946 41.93); --primary-foreground: oklch(0.98 0.016 73.684); @@ -57,7 +57,7 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.5382 0.1949 22.216); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(1 0 0 / 13%); + --border: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 18%); --ring: oklch(0.646 0.222 41.116); --chart-1: oklch(0.488 0.243 264.376); @@ -65,11 +65,11 @@ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.006 285.885); + --sidebar: #040404; --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.646 0.222 41.116); --sidebar-primary-foreground: oklch(0.98 0.016 73.684); - --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent: #131317; --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.646 0.222 41.116); @@ -110,6 +110,15 @@ --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); @@ -166,7 +175,9 @@ p { } @keyframes dot-pulse { - 0%, 80%, 100% { + 0%, + 80%, + 100% { opacity: 0.3; transform: scale(0.8); } @@ -189,7 +200,10 @@ p { /* Only apply custom viewport height on mobile */ @media (max-width: 767px) { .h-screen-safe { - height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */ + height: var( + --vh, + 100vh + ); /* Use CSS variable set by ViewportHeightFix on mobile */ } } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0db1b49bf..9cf66dd28 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; import StoreInternalRedirect from "@app/components/StoreInternalRedirect"; -import { Inter } from "next/font/google"; +import { Inter, Mona_Sans } from "next/font/google"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -36,7 +36,11 @@ const inter = Inter({ subsets: ["latin"] }); -const fontClassName = inter.className; +const monaSans = Mona_Sans({ + subsets: ["latin"] +}); + +const fontClassName = monaSans.className; export default async function RootLayout({ children diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 59a75472b..24dc02a19 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -213,9 +213,9 @@ export const orgNavSections = ( icon: , items: [ { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: + title: "sidebarAlerting", + href: "/{orgId}/settings/alerting", + icon: }, { title: "sidebarProvisioning", @@ -228,9 +228,9 @@ export const orgNavSections = ( icon: }, { - title: "sidebarAlerting", - href: "/{orgId}/settings/alerting", - icon: + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: } ] }, diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 33944db39..ea67b6b73 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -13,6 +13,7 @@ import { import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries } from "@app/lib/queries"; @@ -24,9 +25,14 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; +import type { DataTablePaginationState } from "@app/components/ui/data-table"; +import { useDebouncedCallback } from "use-debounce"; type AlertingRulesTableProps = { orgId: string; + siteId?: number; + resourceId?: number; }; type AlertRuleRow = { @@ -41,6 +47,7 @@ type AlertRuleRow = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; }; function ruleHref(orgId: string, ruleId: number) { @@ -53,10 +60,14 @@ function sourceSummary( ) { if ( rule.eventType === "site_online" || - rule.eventType === "site_offline" + rule.eventType === "site_offline" || + rule.eventType === "site_toggle" ) { return t("alertingSummarySites", { count: rule.siteIds.length }); } + if (rule.eventType.startsWith("resource_")) { + return t("alertingSummaryResources", { count: rule.resourceIds.length }); + } return t("alertingSummaryHealthChecks", { count: rule.healthCheckIds.length }); @@ -71,16 +82,26 @@ function triggerLabel( return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); + case "site_toggle": + return t("alertingTriggerSiteToggle"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); - case "health_check_not_healthy": + case "health_check_unhealthy": return t("alertingTriggerHcUnhealthy"); + case "health_check_toggle": + return t("alertingTriggerHcToggle"); + case "resource_healthy": + return t("alertingTriggerResourceHealthy"); + case "resource_unhealthy": + return t("alertingTriggerResourceUnhealthy"); + case "resource_toggle": + return t("alertingTriggerResourceToggle"); default: return rule.eventType; } } -export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { +export default function AlertingRulesTable({ orgId, siteId, resourceId }: AlertingRulesTableProps) { const router = useRouter(); const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -88,19 +109,52 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.alertingRules); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const [deleteOpen, setDeleteOpen] = useState(false); const [selected, setSelected] = useState(null); const [togglingId, setTogglingId] = useState(null); + const page = Math.max(1, Number(searchParams.get("page") ?? 1)); + const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; + const { - data: rows = [], + data, isLoading, refetch, isRefetching - } = useQuery(orgQueries.alertRules({ orgId })); + } = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query, siteId, resourceId })); + + const rows = data?.alertRules ?? []; + const total = data?.pagination.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount }; + + const handlePaginationChange = (newState: PaginationState) => { + searchParams.set("page", (newState.pageIndex + 1).toString()); + searchParams.set("pageSize", newState.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((value: string) => { + if (value) { + searchParams.set("query", value); + } else { + searchParams.delete("query"); + } + searchParams.delete("page"); + filter({ searchParams }); + }, 300); const invalidate = () => - queryClient.invalidateQueries(orgQueries.alertRules({ orgId })); + queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] }); const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { setTogglingId(rule.alertRuleId); @@ -268,19 +322,22 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { { router.push(`/${orgId}/settings/alerting/create`); }} onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading} + isRefreshing={isRefetching || isLoading || isFiltering} addButtonText={t("alertingAddRule")} enableColumnVisibility stickyLeftColumn="name" stickyRightColumn="rowActions" + pagination={paginationState} + onPaginationChange={handlePaginationChange} /> ); diff --git a/src/components/AutoProvisionConfigWidget.tsx b/src/components/AutoProvisionConfigWidget.tsx index d4df3f50d..4767544d0 100644 --- a/src/components/AutoProvisionConfigWidget.tsx +++ b/src/components/AutoProvisionConfigWidget.tsx @@ -1,19 +1,33 @@ "use client"; -import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; -import { FormDescription } from "@app/components/ui/form"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; import { SwitchInput } from "@app/components/SwitchInput"; -import { useTranslations } from "next-intl"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; -import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import { useTranslations } from "next-intl"; +import type { Control } from "react-hook-form"; type Role = { roleId: number; name: string; }; +export type IdpOrgMappingFieldBinding = { + control: unknown; + name: string; + labelKey?: string; +}; + type AutoProvisionConfigWidgetProps = { autoProvision: boolean; onAutoProvisionChange: (checked: boolean) => void; @@ -28,6 +42,11 @@ type AutoProvisionConfigWidgetProps = { onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void; rawExpression: string; onRawExpressionChange: (expression: string) => void; + orgMappingField: IdpOrgMappingFieldBinding; + showAutoProvisionSwitch?: boolean; + roleMappingFieldIdPrefix?: string; + showFreeformRoleNamesHint?: boolean; + autoProvisionSwitchId?: string; }; export default function AutoProvisionConfigWidget({ @@ -43,41 +62,95 @@ export default function AutoProvisionConfigWidget({ mappingBuilderRules, onMappingBuilderRulesChange, rawExpression, - onRawExpressionChange + onRawExpressionChange, + orgMappingField, + showAutoProvisionSwitch = true, + roleMappingFieldIdPrefix = "org-idp-auto-provision", + showFreeformRoleNamesHint = false, + autoProvisionSwitchId = "auto-provision-toggle" }: AutoProvisionConfigWidgetProps) { const t = useTranslations(); const { isPaidUser } = usePaidStatus(); + const showMappingTabs = showAutoProvisionSwitch === false || autoProvision; + + const orgMappingLabelKey = + orgMappingField.labelKey ?? "orgMappingPathOptional"; + return (
-
- -
+ {showAutoProvisionSwitch && ( +
+ +
+ )} - {autoProvision && ( - + {showMappingTabs && ( + +
+ +
+
+
+

+ {t("defaultMappingsOrgDescription")} +

+ + } + name={orgMappingField.name} + render={({ field }) => ( + + + {t(orgMappingLabelKey)} + + + + + + + )} + /> +
+
+
)}
); diff --git a/src/components/DomainPageClient.tsx b/src/components/DomainPageClient.tsx new file mode 100644 index 000000000..31527c5b8 --- /dev/null +++ b/src/components/DomainPageClient.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { domainQueries } from "@app/lib/queries"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { GetDNSRecordsResponse } from "@server/routers/domain"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import DNSRecordsTable from "@app/components/DNSRecordTable"; +import RestartDomainButton from "@app/components/RestartDomainButton"; +import RefreshButton from "@app/components/RefreshButton"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainCertForm from "@app/components/DomainCertForm"; +import { build } from "@server/build"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; + +interface DomainPageClientProps { + initialDomain: GetDomainResponse; + initialDnsRecords: GetDNSRecordsResponse; + orgId: string; + domainId: string; +} + +export default function DomainPageClient({ + initialDomain, + initialDnsRecords, + orgId, + domainId +}: DomainPageClientProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + + const { data: domain, refetch: refetchDomain } = useQuery({ + ...domainQueries.getDomain({ orgId, domainId }), + initialData: initialDomain + }); + + const { data: dnsRecords, refetch: refetchDnsRecords } = useQuery({ + ...domainQueries.getDNSRecords({ orgId, domainId }), + initialData: initialDnsRecords + }); + + const refetchAll = () => { + refetchDomain(); + refetchDnsRecords(); + }; + + return ( + <> +
+ + {env.flags.usePangolinDns && domain.failed ? ( + + ) : ( + + )} +
+
+ {build !== "oss" && env.flags.usePangolinDns ? ( + + ) : null} + + ({ + ...r, + id: String(r.id) + }))} + type={domain.type} + /> + + {domain.type === "wildcard" && !domain.configManaged && ( + + )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index f5cb1ae74..2c3abeb1a 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -10,13 +10,12 @@ import { MoreHorizontal, RefreshCw } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Badge } from "@app/components/ui/badge"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; @@ -34,6 +33,10 @@ import { TooltipTrigger } from "./ui/tooltip"; import Link from "next/link"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { orgQueries } from "@app/lib/queries"; +import { toUnicode } from "punycode"; +import { durationToMs } from "@app/lib/durationToMs"; export type DomainRow = { domainId: string; @@ -59,32 +62,32 @@ export default function DomainsTable({ domains, orgId }: Props) { const [selectedDomain, setSelectedDomain] = useState( null ); - const [isRefreshing, setIsRefreshing] = useState(false); const [restartingDomains, setRestartingDomains] = useState>( new Set() ); const env = useEnvContext(); const api = createApiClient(env); - const router = useRouter(); const t = useTranslations(); const { toast } = useToast(); const { org } = useOrgContext(); + const queryClient = useQueryClient(); - const refreshData = async () => { - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); - } - }; + const { data: rawDomains, isRefetching, refetch } = useQuery({ + ...orgQueries.domains({ orgId }), + initialData: domains as any, + refetchInterval: durationToMs(10, "seconds") + }); + + const tableData = useMemo( + () => + (rawDomains ?? []).map((d) => ({ + ...d, + baseDomain: toUnicode(d.baseDomain), + type: d.type ?? "", + errorMessage: d.errorMessage ?? null + } as DomainRow)), + [rawDomains] + ); const deleteDomain = async (domainId: string) => { try { @@ -94,7 +97,7 @@ export default function DomainsTable({ domains, orgId }: Props) { description: t("domainDeletedDescription") }); setIsDeleteModalOpen(false); - refreshData(); + refetch(); } catch (e) { toast({ title: t("error"), @@ -114,7 +117,7 @@ export default function DomainsTable({ domains, orgId }: Props) { fallback: "Domain verification restarted successfully" }) }); - refreshData(); + refetch(); } catch (e) { toast({ title: t("error"), @@ -361,16 +364,16 @@ export default function DomainsTable({ domains, orgId }: Props) { open={isCreateModalOpen} setOpen={setIsCreateModalOpen} onCreated={(domain) => { - refreshData(); + refetch(); }} /> setIsCreateModalOpen(true)} - onRefresh={refreshData} - isRefreshing={isRefreshing} + onRefresh={refetch} + isRefreshing={isRefetching} /> ); diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index f29fccccd..671a16e7d 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -41,6 +41,11 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { SitesSelector } from "@app/components/site-selector"; +import type { Selectedsite } from "@app/components/site-selector"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { cn } from "@app/lib/cn"; export type HealthCheckConfig = { hcEnabled: boolean; @@ -84,6 +89,9 @@ export type HealthCheckRow = { resourceId: number | null; resourceName: string | null; resourceNiceId: string | null; + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; }; export type HealthCheckCredenzaProps = @@ -132,6 +140,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [selectedSite, setSelectedSite] = useState(null); const healthCheckSchema = z .object({ @@ -280,8 +289,14 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { hcStatus: initialValues.hcStatus ?? null, hcHeaders: parsedHeaders }); + if (initialValues.siteId && initialValues.siteName) { + setSelectedSite({ siteId: initialValues.siteId, name: initialValues.siteName, type: "" }); + } else { + setSelectedSite(null); + } } else { form.reset(DEFAULT_VALUES); + setSelectedSite(null); } } }, [open]); @@ -331,6 +346,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { try { const payload = { name: (values as any).name, + siteId: selectedSite?.siteId, hcEnabled: values.hcEnabled, hcMode: values.hcMode, hcScheme: values.hcScheme, @@ -439,6 +455,42 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { /> )} + {/* Site picker (submit mode only) */} + {mode === "submit" && ( +
+ + {t("site")} + + + + + + { + setSelectedSite(site); + }} + /> + + + +
+ )} +
(null); const [togglingId, setTogglingId] = useState(null); + const page = Math.max(1, Number(searchParams.get("page") ?? 1)); + const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20)); + const pageIndex = page - 1; + const query = searchParams.get("query") ?? undefined; + const { - data: rows = [], + data, isLoading, refetch, isRefetching } = useQuery({ - ...orgQueries.standaloneHealthChecks({ orgId }), + ...orgQueries.standaloneHealthChecks({ + orgId, + limit: pageSize, + offset: pageIndex * pageSize, + query + }), refetchInterval: 10_000 }); + const rows = data?.healthChecks ?? []; + const total = data?.pagination.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + const paginationState: DataTablePaginationState = { + pageIndex, + pageSize, + pageCount + }; + + const handlePaginationChange = (newState: PaginationState) => { + searchParams.set("page", (newState.pageIndex + 1).toString()); + searchParams.set("pageSize", newState.pageSize.toString()); + filter({ searchParams }); + }; + + const handleSearchChange = useDebouncedCallback((value: string) => { + if (value) { + searchParams.set("query", value); + } else { + searchParams.delete("query"); + } + searchParams.delete("page"); + filter({ searchParams }); + }, 300); + const invalidate = () => - queryClient.invalidateQueries( - orgQueries.standaloneHealthChecks({ orgId }) - ); + queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] + }); const handleToggleEnabled = async ( row: HealthCheckRow, @@ -194,6 +240,27 @@ export default function HealthChecksTable({ ); } }, + { + id: "site", + friendlyName: "Site", + header: () => ( + Site + ), + cell: ({ row }) => { + const r = row.original; + if (!r.siteId || !r.siteName || !r.siteNiceId) { + return -; + } + return ( + + + + ); + } + }, { id: "health", friendlyName: t("standaloneHcColumnHealth"), @@ -341,21 +408,24 @@ export default function HealthChecksTable({ { setSelected(null); setCredenzaOpen(true); }} addButtonDisabled={!isPaid} onRefresh={() => refetch()} - isRefreshing={isRefetching || isLoading} + isRefreshing={isRefetching || isLoading || isFiltering} addButtonText={t("standaloneHcAddButton")} enableColumnVisibility stickyLeftColumn="name" stickyRightColumn="rowActions" + pagination={paginationState} + onPaginationChange={handlePaginationChange} /> ); diff --git a/src/components/IdpGlobalModeBanner.tsx b/src/components/IdpGlobalModeBanner.tsx index 9f864b36d..5e2709e6f 100644 --- a/src/components/IdpGlobalModeBanner.tsx +++ b/src/components/IdpGlobalModeBanner.tsx @@ -8,23 +8,25 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; +import type { Env } from "@app/lib/types/env"; + +export function isIdpGlobalModeBannerVisible(env: Env): boolean { + if (build === "saas") { + return false; + } + return env.app.identityProviderMode === undefined; +} export function IdpGlobalModeBanner() { const t = useTranslations(); const { env } = useEnvContext(); const { isPaidUser, hasEnterpriseLicense } = usePaidStatus(); - const identityProviderModeUndefined = - env.app.identityProviderMode === undefined; const paidUserForOrgOidc = isPaidUser(tierMatrix.orgOidc); const enterpriseUnlicensed = build === "enterprise" && !hasEnterpriseLicense; - if (build === "saas") { - return null; - } - - if (!identityProviderModeUndefined) { + if (!isIdpGlobalModeBannerVisible(env)) { return null; } diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx index 50d849812..4fc4c9901 100644 --- a/src/components/IdpLoginButtons.tsx +++ b/src/components/IdpLoginButtons.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { Button } from "@app/components/ui/button"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { useTranslations } from "next-intl"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; import { generateOidcUrlProxy, type GenerateOidcUrlResponse @@ -135,24 +135,7 @@ export default function IdpLoginButtons({ disabled={loading} loading={loading} > - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/IdpTypeBadge.tsx b/src/components/IdpTypeBadge.tsx index b0e90660b..d18c96d9b 100644 --- a/src/components/IdpTypeBadge.tsx +++ b/src/components/IdpTypeBadge.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@app/components/ui/badge"; -import Image from "next/image"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; type IdpTypeBadgeProps = { type: string; @@ -29,34 +29,8 @@ export default function IdpTypeBadge({ variant="secondary" className="inline-flex items-center space-x-1 w-fit" > - {effectiveType === "google" && ( - <> - Google - {effectiveName} - - )} - {effectiveType === "azure" && ( - <> - Azure - {effectiveName} - - )} - {effectiveType === "oidc" && {effectiveName}} - {!["google", "azure", "oidc"].includes(effectiveType) && ( - {effectiveName} - )} + + {effectiveName} ); } diff --git a/src/components/IdpTypeIcon.tsx b/src/components/IdpTypeIcon.tsx new file mode 100644 index 000000000..be49f9654 --- /dev/null +++ b/src/components/IdpTypeIcon.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import Image from "next/image"; +import { ReactNode } from "react"; + +type Props = { + type?: string | null; + variant?: string | null; + size?: number; + className?: string; + alt?: string; + fallback?: ReactNode; +}; + +export default function IdpTypeIcon({ + type, + variant, + size = 16, + className, + alt, + fallback = null +}: Props) { + const effectiveType = (variant || type || "").toLowerCase(); + + let src: string | null = null; + let defaultAlt = ""; + + if (effectiveType === "google") { + src = "/idp/google.png"; + defaultAlt = "Google"; + } else if (effectiveType === "azure") { + src = "/idp/azure.png"; + defaultAlt = "Azure"; + } else if (effectiveType === "oidc") { + src = "/idp/openid.png"; + defaultAlt = "OAuth2/OIDC"; + } + + if (!src) { + return <>{fallback}; + } + + return ( + {alt + ); +} diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index e8574b29e..fce96894c 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1542,7 +1542,7 @@ export function InternalResourceForm({ ) )} - + {t( "accessClientSelect" )} diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index bef016853..29850f115 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { return (
-
+
diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 7c3ade008..a66a8300b 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -136,7 +136,7 @@ export function LayoutSidebar({ return (
@@ -165,7 +165,7 @@ export function LayoutSidebar({
{/* Fade gradient at bottom to indicate scrollable content */} -
+
{isSidebarCollapsed && ( @@ -217,7 +217,7 @@ export function LayoutSidebar({ setHasManualToggle(true); setSidebarStateCookie(false); }} - className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors" + className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 transition-colors" aria-label={t("sidebarExpand")} > @@ -231,12 +231,17 @@ export function LayoutSidebar({
)} -
- {canShowProductUpdates && ( +
+ {canShowProductUpdates ? (
- )} + ) :
} {showTrial && (
diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index 5d7ece74e..b6f65aa7c 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -53,7 +53,7 @@ export default function LocaleSwitcherSelect({ )} aria-label={label} > - + {selected?.label ?? label} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index c3b1fc384..e87a8b1a8 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -27,7 +27,6 @@ import { LockIcon } from "lucide-react"; import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton"; import { createApiClient } from "@app/lib/api"; import Link from "next/link"; -import Image from "next/image"; import { GenerateOidcUrlResponse } from "@server/routers/idp"; import { Separator } from "./ui/separator"; import { useTranslations } from "next-intl"; @@ -37,6 +36,7 @@ import { } from "@app/actions/server"; import { redirect as redirectTo } from "next/navigation"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; // @ts-ignore import { loadReoScript } from "reodotdev"; import { build } from "@server/build"; @@ -393,24 +393,7 @@ export default function LoginForm({ loginWithIdp(idp.idpId); }} > - {effectiveType === "google" && ( - Google - )} - {effectiveType === "azure" && ( - Azure - )} + {idp.name} ); diff --git a/src/components/OrgIdpDataTable.tsx b/src/components/OrgIdpDataTable.tsx index 9a45f49e8..fe15b6cc9 100644 --- a/src/components/OrgIdpDataTable.tsx +++ b/src/components/OrgIdpDataTable.tsx @@ -1,19 +1,26 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; +import { + DataTable, + type DataTableAddAction +} from "@app/components/ui/data-table"; import { useTranslations } from "next-intl"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; onAdd?: () => void; + addActions?: DataTableAddAction[]; + addButtonDisabled?: boolean; } export function IdpDataTable({ columns, data, - onAdd + onAdd, + addActions, + addButtonDisabled }: DataTableProps) { const t = useTranslations(); @@ -27,6 +34,8 @@ export function IdpDataTable({ searchColumn="name" addButtonText={t("idpAdd")} onAdd={onAdd} + addActions={addActions} + addButtonDisabled={addButtonDisabled} enableColumnVisibility={true} stickyRightColumn="actions" /> diff --git a/src/components/OrgIdpTable.tsx b/src/components/OrgIdpTable.tsx index 8f53f4847..bdbaafa27 100644 --- a/src/components/OrgIdpTable.tsx +++ b/src/components/OrgIdpTable.tsx @@ -4,13 +4,37 @@ import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { IdpDataTable } from "@app/components/OrgIdpDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + ArrowRight, + ArrowUpDown, + KeyRound, + MoreHorizontal +} from "lucide-react"; +import { useMemo, useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; import { useRouter } from "next/navigation"; import { DropdownMenu, @@ -21,6 +45,14 @@ import { import Link from "next/link"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; +import IdpTypeIcon from "@app/components/IdpTypeIcon"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "use-debounce"; +import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types"; +import { cn } from "@app/lib/cn"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner"; export type IdpRow = { idpId: number; @@ -29,6 +61,15 @@ export type IdpRow = { variant?: string; }; +type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number]; + +function IdpImportRowIcon({ + type, + variant +}: Pick) { + return ; +} + type Props = { idps: IdpRow[]; orgId: string; @@ -37,10 +78,53 @@ type Props = { export default function IdpTable({ idps, orgId }: Props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedIdp, setSelectedIdp] = useState(null); - const api = createApiClient(useEnvContext()); + const [isUnassociateModalOpen, setIsUnassociateModalOpen] = useState(false); + const [selectedUnassociateIdp, setSelectedUnassociateIdp] = + useState(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importSearchQuery, setImportSearchQuery] = useState(""); + const [importSubmitting, setImportSubmitting] = useState(false); + const [debouncedImportSearch] = useDebounce(importSearchQuery, 150); + + const envContext = useEnvContext(); + const api = createApiClient(envContext); + const { user } = useUserContext(); + const { isPaidUser } = usePaidStatus(); const router = useRouter(); const t = useTranslations(); + const canImportOrgOidcIdp = isPaidUser(tierMatrix.orgOidc); + const addIdpDisabled = isIdpGlobalModeBannerVisible(envContext.env); + + const { data: adminIdpsRaw = [] } = useQuery({ + queryKey: ["admin-org-idps", user.userId], + queryFn: async () => { + const res = await api.get<{ + data: ListUserAdminOrgIdpsResponse; + }>(`/user/${user.userId}/admin-org-idps`); + return res.data.data.idps; + }, + enabled: importDialogOpen && !!user?.userId + }); + + const importableIdps = useMemo(() => { + const localIds = new Set(idps.map((i) => i.idpId)); + return adminIdpsRaw.filter( + (row) => row.orgId !== orgId && !localIds.has(row.idpId) + ); + }, [adminIdpsRaw, orgId, idps]); + + const shownImportIdps = useMemo(() => { + const q = debouncedImportSearch.trim().toLowerCase(); + if (!q) { + return importableIdps; + } + return importableIdps.filter((row) => { + const hay = `${row.orgName} ${row.name}`.toLowerCase(); + return hay.includes(q); + }); + }, [importableIdps, debouncedImportSearch]); + const deleteIdp = async (idpId: number) => { try { await api.delete(`/org/${orgId}/idp/${idpId}`); @@ -59,24 +143,50 @@ export default function IdpTable({ idps, orgId }: Props) { } }; + const importIdp = async (row: AdminIdpRow) => { + setImportSubmitting(true); + try { + await api.post(`/org/${orgId}/idp/${row.idpId}/import`, { + sourceOrgId: row.orgId + }); + toast({ + title: t("success"), + description: t("idpImportedDescription") + }); + setImportDialogOpen(false); + setImportSearchQuery(""); + router.refresh(); + router.push(`/${orgId}/settings/idp/${row.idpId}/general`); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setImportSubmitting(false); + } + }; + + const unassociateIdp = async (idpId: number) => { + try { + await api.delete(`/org/${orgId}/idp/${idpId}/association`); + toast({ + title: t("success"), + description: t("idpUnassociatedDescription") + }); + setIsUnassociateModalOpen(false); + router.refresh(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + const columns: ExtendedColumnDef[] = [ - { - accessorKey: "idpId", - friendlyName: "ID", - header: ({ column }) => { - return ( - - ); - } - }, { accessorKey: "name", friendlyName: t("name"), @@ -142,6 +252,14 @@ export default function IdpTable({ idps, orgId }: Props) { {t("viewSettings")} + { + setSelectedUnassociateIdp(siteRow); + setIsUnassociateModalOpen(true); + }} + > + {t("idpUnassociateMenu")} + { setSelectedIdp(siteRow); @@ -149,7 +267,7 @@ export default function IdpTable({ idps, orgId }: Props) { }} > - {t("delete")} + {t("idpDeleteAllOrgsMenu")} @@ -179,8 +297,8 @@ export default function IdpTable({ idps, orgId }: Props) { }} dialog={
-

{t("idpQuestionRemove")}

-

{t("idpMessageRemove")}

+

{t("idpDeleteGlobalQuestion")}

+

{t("idpDeleteGlobalDescription")}

} buttonText={t("idpConfirmDelete")} @@ -189,11 +307,127 @@ export default function IdpTable({ idps, orgId }: Props) { title={t("idpDelete")} /> )} + {selectedUnassociateIdp && ( + { + setIsUnassociateModalOpen(val); + setSelectedUnassociateIdp(null); + }} + dialog={ +
+

{t("idpUnassociateQuestion")}

+

{t("idpUnassociateDescription")}

+
+ } + buttonText={t("idpUnassociateConfirm")} + onConfirm={async () => + unassociateIdp(selectedUnassociateIdp.idpId) + } + string={selectedUnassociateIdp.name} + title={t("idpUnassociateTitle")} + warningText={t("idpUnassociateWarning")} + /> + )} + + { + setImportDialogOpen(open); + if (!open) { + setImportSearchQuery(""); + } + }} + > + + + + {t("idpImportDialogTitle")} + + + {t("idpImportDialogDescription")} + + + + + + + + {t("idpImportEmpty")} + + + {shownImportIdps.map((row) => ( + { + if (!canImportOrgOidcIdp) { + return; + } + void importIdp(row); + }} + > +
+ +
+
+
+ {row.orgName} +
+
+ {row.name} +
+
+
+ ))} +
+
+
+
+ + + + + +
+
router.push(`/${orgId}/settings/idp/create`)} + addButtonDisabled={addIdpDisabled} + addActions={[ + { + label: t("idpAddActionCreateNew"), + onSelect: () => { + router.push(`/${orgId}/settings/idp/create`); + } + }, + { + label: t("idpAddActionImportFromOrg"), + onSelect: () => { + setImportDialogOpen(true); + } + } + ]} /> ); diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index fcbc700a2..5f77582f5 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -76,8 +76,8 @@ export function OrgSelector({ className={cn( "cursor-pointer transition-colors", isCollapsed - ? "w-full h-16 flex items-center justify-center hover:bg-muted" - : "w-full px-5 py-4 hover:bg-muted" + ? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50" + : "w-full px-5 py-4 hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50" )} > {isCollapsed ? ( @@ -172,7 +172,7 @@ export function OrgSelector({ + ) : ( + + ); + + return ( + <> + + +
+
+ Uptime + + Site availability over the last {days} days. + +
+ {alertButton} +
+
+ + + +
+ + + + + Create Email Alert + + Get notified by email when this{" "} + {siteId ? "site" : "resource"} goes offline or + comes back online. + + + +
+
+ + setName(e.target.value)} + placeholder="Alert name" + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(userTags) + : newTags; + setUserTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allUsers} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(roleTags) + : newTags; + setRoleTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allRoles} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(emailTags) + : newTags; + setEmailTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> +
+
+
+ + + + + + +
+
+ + ); +} diff --git a/src/components/ViewDevicesDialog.tsx b/src/components/ViewDevicesDialog.tsx index 9c29b219c..b29cdcc75 100644 --- a/src/components/ViewDevicesDialog.tsx +++ b/src/components/ViewDevicesDialog.tsx @@ -27,7 +27,7 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { Loader2, RefreshCw } from "lucide-react"; import moment from "moment"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -58,7 +58,6 @@ export default function ViewDevicesDialog({ const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState<"available" | "archived">("available"); const fetchDevices = async () => { setLoading(true); @@ -177,34 +176,21 @@ export default function ViewDevicesDialog({
) : ( - - setActiveTab(value as "available" | "archived") - } - className="w-full" + !d.archived).length})`, + href: "#available" + }, + { + title: `${t("archived") || "Archived"} (${devices.filter((d) => d.archived).length})`, + href: "#archived" + } + ]} > - - - {t("available") || "Available"} ( - { - devices.filter( - (d) => !d.archived - ).length - } - ) - - - {t("archived") || "Archived"} ( - { - devices.filter( - (d) => d.archived - ).length - } - ) - - - +
{devices.filter((d) => !d.archived) .length === 0 ? (
@@ -271,8 +257,8 @@ export default function ViewDevicesDialog({
)} - - +
+
{devices.filter((d) => d.archived) .length === 0 ? (
@@ -336,8 +322,8 @@ export default function ViewDevicesDialog({
)} - - +
+
)} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 2037d50a8..e92980171 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -35,6 +35,7 @@ import { RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; +import { StrategySelect } from "@app/components/StrategySelect"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { @@ -43,54 +44,115 @@ import { } from "@app/lib/alertRuleForm"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; +import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Control, UseFormReturn } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; -export function DropdownAddAction({ +export function AddActionPanel({ onAdd }: { onAdd: (type: AlertRuleFormAction["type"]) => void; }) { const t = useTranslations(); - const [open, setOpen] = useState(false); + + + const EXTERNAL_INTEGRATIONS = [ + { + id: "pagerduty", + name: "PagerDuty", + logo: "/third-party/pgd.png", + description: "Send alerts to PagerDuty for incident management", + descriptionKey: t("alertingExternalPagerDutyDescription") + }, + { + id: "opsgenie", + name: "Opsgenie", + logo: "/third-party/opsgenie.png", + description: "Route alerts to Opsgenie for on-call management", + descriptionKey: t("alertingExternalOpsgenieDescription") + }, + { + id: "servicenow", + name: "ServiceNow", + logo: "/third-party/servicenow.png", + description: "Create ServiceNow incidents from alert events", + descriptionKey: t("alertingExternalServiceNowDescription") + }, + { + id: "incidentio", + name: "Incident.io", + logo: "/third-party/incidentio.png", + description: "Trigger Incident.io workflows from alert events", + descriptionKey: t("alertingExternalIncidentIoDescription") + } + ] as const; + + const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); + + const [selected, setSelected] = useState(null); + + const isPremiumSelected = + selected !== null && EXTERNAL_IDS.includes(selected as any); + const isBuiltInSelected = selected !== null && !isPremiumSelected; + + const actionTypeOptions = [ + { + id: "notify", + title: t("alertingActionNotify"), + description: t("alertingActionNotifyDescription"), + icon: + }, + { + id: "webhook", + title: t("alertingActionWebhook"), + description: t("alertingActionWebhookDescription"), + icon: + }, + ...EXTERNAL_INTEGRATIONS.map((integration) => ({ + id: integration.id, + title: integration.name, + description: integration.description, + icon: ( + {integration.name} + ) + })) + ]; + + const handleAdd = () => { + if (!isBuiltInSelected) return; + onAdd(selected as AlertRuleFormAction["type"]); + setSelected(null); + }; + return ( - - - - - - - - - { - onAdd("notify"); - setOpen(false); - }} - > - {t("alertingActionNotify")} - - { - onAdd("webhook"); - setOpen(false); - }} - > - {t("alertingActionWebhook")} - - - - - - + )} +
); } @@ -275,6 +337,93 @@ function HealthCheckMultiSelect({ ); } +function ResourceMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + + const { data: resources = [] } = useQuery( + orgQueries.resources({ orgId, query: debounced, perPage: 10 }) + ); + + const shown = useMemo(() => { + return resources; + }, [resources]); + + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + + const summary = + value.length === 0 + ? t("alertingSelectResources") + : t("alertingResourcesSelected", { count: value.length }); + + return ( + + + + + + + + + + {t("alertingResourcesEmpty")} + + + {shown.map((r) => ( + toggle(r.resourceId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} + export function ActionBlock({ orgId, index, @@ -294,6 +443,20 @@ export function ActionBlock({ }) { const t = useTranslations(); const type = useWatch({ control, name: `actions.${index}.type` }); + + const typeHeader = + type === "notify" ? ( +
+ + {t("alertingActionNotify")} +
+ ) : ( +
+ + {t("alertingActionWebhook")} +
+ ); + return (
{canRemove && ( @@ -307,55 +470,7 @@ export function ActionBlock({ )} - ( - - {t("alertingActionType")} - - - )} - /> + {typeHeader} {type === "notify" && ( (null); - const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId })); - const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId })); + const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(orgQueries.users({ orgId })); + const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(orgQueries.roles({ orgId })); const allUsers = useMemo( () => @@ -420,6 +535,50 @@ function NotifyActionFields({ [orgRoles] ); + const hasResolvedTagsRef = useRef(false); + + useEffect(() => { + if (isLoadingUsers || isLoadingRoles) return; + if (hasResolvedTagsRef.current) return; + + const currentUserTags = form.getValues( + `actions.${index}.userTags` + ) as Tag[]; + const currentRoleTags = form.getValues( + `actions.${index}.roleTags` + ) as Tag[]; + + const resolvedUserTags = currentUserTags.map((tag) => { + const match = allUsers.find((u) => u.id === tag.id); + return match ? { id: tag.id, text: match.text } : tag; + }); + + const resolvedRoleTags = currentRoleTags.map((tag) => { + const match = allRoles.find((r) => r.id === tag.id); + return match ? { id: tag.id, text: match.text } : tag; + }); + + const userTagsNeedUpdate = resolvedUserTags.some( + (t, i) => t.text !== currentUserTags[i]?.text + ); + const roleTagsNeedUpdate = resolvedRoleTags.some( + (t, i) => t.text !== currentRoleTags[i]?.text + ); + + if (userTagsNeedUpdate) { + form.setValue(`actions.${index}.userTags`, resolvedUserTags, { + shouldDirty: false + }); + } + if (roleTagsNeedUpdate) { + form.setValue(`actions.${index}.roleTags`, resolvedRoleTags, { + shouldDirty: false + }); + } + + hasResolvedTagsRef.current = true; + }, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); + const userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[]; const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[]; const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[]; @@ -870,6 +1029,58 @@ export function AlertRuleSourceFields({ const t = useTranslations(); const { setValue, getValues } = useFormContext(); const sourceType = useWatch({ control, name: "sourceType" }); + const allSites = useWatch({ control, name: "allSites" }); + const allHealthChecks = useWatch({ control, name: "allHealthChecks" }); + const allResources = useWatch({ control, name: "allResources" }); + + const siteStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllSites"), + description: t("alertingAllSitesDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificSites"), + description: t("alertingSpecificSitesDescription") + } + ], + [t] + ); + + const healthCheckStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllHealthChecks"), + description: t("alertingAllHealthChecksDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificHealthChecks"), + description: t("alertingSpecificHealthChecksDescription") + } + ], + [t] + ); + + const resourceStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllResources"), + description: t("alertingAllResourcesDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificResources"), + description: t("alertingSpecificResourcesDescription") + } + ], + [t] + ); + return (
{t("alertingSourceHealthCheck")} + + {t("alertingSourceResource")} + @@ -925,39 +1153,131 @@ export function AlertRuleSourceFields({ )} /> {sourceType === "site" ? ( - ( - - {t("alertingPickSites")} - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("siteIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allSites && ( + ( + + + {t("alertingPickSites")} + + + + + )} + /> )} - /> + + ) : sourceType === "resource" ? ( + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("resourceIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allResources && ( + ( + + + {t("alertingPickResources")} + + + + + )} + /> + )} + ) : ( - ( - - - {t("alertingPickHealthChecks")} - - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("healthCheckIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allHealthChecks && ( + ( + + + {t("alertingPickHealthChecks")} + + + + + )} + /> )} - /> + )}
); @@ -990,6 +1310,9 @@ export function AlertRuleTriggerFields({ {sourceType === "site" ? ( <> + + {t("alertingTriggerSiteToggle")} + {t("alertingTriggerSiteOnline")} @@ -997,8 +1320,23 @@ export function AlertRuleTriggerFields({ {t("alertingTriggerSiteOffline")} + ) : sourceType === "resource" ? ( + <> + + {t("alertingTriggerResourceToggle")} + + + {t("alertingTriggerResourceHealthy")} + + + {t("alertingTriggerResourceUnhealthy")} + + ) : ( <> + + {t("alertingTriggerHcToggle")} + {t("alertingTriggerHcHealthy")} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index e0e8e0a3e..6d7d8d076 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -2,9 +2,9 @@ import { ActionBlock, + AddActionPanel, AlertRuleSourceFields, - AlertRuleTriggerFields, - DropdownAddAction + AlertRuleTriggerFields } from "@app/components/alert-rule-editor/AlertRuleFields"; import { SettingsContainer } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; @@ -82,6 +82,12 @@ function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) { } return t("alertingSummarySites", { count: v.siteIds.length }); } + if (v.sourceType === "resource") { + if (v.resourceIds.length === 0) { + return t("alertingNodeNotConfigured"); + } + return t("alertingSummaryResources", { count: v.resourceIds.length }); + } if (v.healthCheckIds.length === 0) { return t("alertingNodeNotConfigured"); } @@ -94,10 +100,20 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); + case "site_toggle": + return t("alertingTriggerSiteToggle"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); case "health_check_unhealthy": return t("alertingTriggerHcUnhealthy"); + case "health_check_toggle": + return t("alertingTriggerHcToggle"); + case "resource_healthy": + return t("alertingTriggerResourceHealthy"); + case "resource_unhealthy": + return t("alertingTriggerResourceUnhealthy"); + case "resource_toggle": + return t("alertingTriggerResourceToggle"); default: return v.trigger; } @@ -330,13 +346,21 @@ export default function AlertRuleGraphEditor({ useWatch({ control: form.control, name: "enabled" }) ?? true; const wSourceType = useWatch({ control: form.control, name: "sourceType" }) ?? "site"; + const wAllSites = + useWatch({ control: form.control, name: "allSites" }) ?? true; const wSiteIds = useWatch({ control: form.control, name: "siteIds" }) ?? []; + const wAllHealthChecks = + useWatch({ control: form.control, name: "allHealthChecks" }) ?? true; const wHealthCheckIds = useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; + const wAllResources = + useWatch({ control: form.control, name: "allResources" }) ?? true; + const wResourceIds = + useWatch({ control: form.control, name: "resourceIds" }) ?? []; const wTrigger = useWatch({ control: form.control, name: "trigger" }) ?? - "site_offline"; + "site_toggle"; const wActions = useWatch({ control: form.control, name: "actions" }) ?? []; @@ -345,8 +369,12 @@ export default function AlertRuleGraphEditor({ name: wName, enabled: wEnabled, sourceType: wSourceType, + allSites: wAllSites, siteIds: wSiteIds, + allHealthChecks: wAllHealthChecks, healthCheckIds: wHealthCheckIds, + allResources: wAllResources, + resourceIds: wResourceIds, trigger: wTrigger, actions: wActions }), @@ -354,8 +382,12 @@ export default function AlertRuleGraphEditor({ wName, wEnabled, wSourceType, + wAllSites, wSiteIds, + wAllHealthChecks, wHealthCheckIds, + wAllResources, + wResourceIds, wTrigger, wActions ] @@ -673,47 +705,43 @@ export default function AlertRuleGraphEditor({ )} {isActionsSidebar && (
-
- - {t( - "alertingSectionActions" - )} - - { - const newIndex = - fields.length; - if (type === "notify") { - append({ - type: "notify", - userTags: [], - roleTags: [], - emailTags: [] - }); - } else { - append({ - type: "webhook", - url: "", - method: "POST", - headers: [ - { - key: "", - value: "" - } - ], - authType: "none", - bearerToken: "", - basicCredentials: "", - customHeaderName: "", - customHeaderValue: "" - }); - } - setSelectedStep( - `action-${newIndex}` - ); - }} - /> -
+ + {t("alertingSectionActions")} + + { + const newIndex = + fields.length; + if (type === "notify") { + append({ + type: "notify", + userTags: [], + roleTags: [], + emailTags: [] + }); + } else { + append({ + type: "webhook", + url: "", + method: "POST", + headers: [ + { + key: "", + value: "" + } + ], + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "" + }); + } + setSelectedStep( + `action-${newIndex}` + ); + }} + /> {fields.map((f, index) => ( } ]; if (hideTemplates) { @@ -44,29 +45,13 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) { id: "google", title: t("idpGoogleTitle"), description: t("idpGoogleDescription"), - icon: ( - {t("idpGoogleAlt")} - ) + icon: }, { id: "azure", title: t("idpAzureTitle"), description: t("idpAzureDescription"), - icon: ( - {t("idpAzureAlt")} - ) + icon: } ]; }, [hideTemplates, t]); diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 34a35455c..1690d92a8 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -18,12 +18,14 @@ import { TableRow } from "@/components/ui/table"; import { DataTablePagination } from "@app/components/DataTablePagination"; +import type { DataTableAddAction } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger @@ -31,7 +33,14 @@ import { import { Input } from "@app/components/ui/input"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; -import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react"; +import { + ChevronDown, + Columns, + Filter, + Plus, + RefreshCw, + Search +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; @@ -67,6 +76,8 @@ type ControlledDataTableProps = { tableId: string; addButtonText?: string; onAdd?: () => void; + addActions?: DataTableAddAction[]; + addButtonDisabled?: boolean; onRefresh?: () => void; isRefreshing?: boolean; refreshButtonDisabled?: boolean; @@ -90,6 +101,8 @@ export function ControlledDataTable({ rows, addButtonText, onAdd, + addActions, + addButtonDisabled = false, onRefresh, isRefreshing, refreshButtonDisabled = false, @@ -348,16 +361,49 @@ export function ControlledDataTable({
)} - {onAdd && addButtonText && ( + {addActions && addActions.length > 0 && addButtonText ? (
- + + + + + + {addActions.map((action, i) => ( + + action.onSelect() + } + > + {action.label} + + ))} + +
+ ) : ( + onAdd && + addButtonText && ( +
+ +
+ ) )}
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index c62afd329..cf252f3ea 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; +import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, CardContent, @@ -46,6 +46,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger @@ -165,12 +166,20 @@ export type DataTablePaginationState = PaginationState & { export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; +/** When set (non-empty), replaces the single add button with a dropdown; `onAdd` is not used. */ +export type DataTableAddAction = { + label: string; + onSelect: () => void; +}; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; title?: string; addButtonText?: string; onAdd?: () => void; + /** Prefer over `onAdd` when non-empty. */ + addActions?: DataTableAddAction[]; addButtonDisabled?: boolean; onRefresh?: () => void; isRefreshing?: boolean; @@ -205,6 +214,7 @@ export function DataTable({ title, addButtonText, onAdd, + addActions, addButtonDisabled = false, onRefresh, isRefreshing, @@ -637,13 +647,45 @@ export function DataTable({
)} - {onAdd && addButtonText && ( + {addActions && addActions.length > 0 && addButtonText ? (
- + + + + + + {addActions.map((action, i) => ( + + action.onSelect() + } + > + {action.label} + + ))} + +
+ ) : ( + onAdd && + addButtonText && ( +
+ +
+ ) )}
diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 2756ca165..f7f96e927 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -13,14 +13,19 @@ export const tagSchema = z.object({ // --------------------------------------------------------------------------- // Form-layer types // NOTE: the form uses "health_check_unhealthy" internally; it maps to the -// backend's "health_check_not_healthy" at the API boundary. +// backend's "health_check_unhealthy" at the API boundary. // --------------------------------------------------------------------------- export type AlertTrigger = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_unhealthy"; + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; export type AlertRuleFormAction = | { @@ -44,9 +49,13 @@ export type AlertRuleFormAction = export type AlertRuleFormValues = { name: string; enabled: boolean; - sourceType: "site" | "health_check"; + sourceType: "site" | "health_check" | "resource"; + allSites: boolean; siteIds: number[]; + allHealthChecks: boolean; healthCheckIds: number[]; + allResources: boolean; + resourceIds: number[]; trigger: AlertTrigger; actions: AlertRuleFormAction[]; }; @@ -60,13 +69,22 @@ export type AlertRuleApiPayload = { eventType: | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; enabled: boolean; + allSites: boolean; siteIds: number[]; + allHealthChecks: boolean; healthCheckIds: number[]; + allResources: boolean; + resourceIds: number[]; userIds: string[]; - roleIds: string[]; + roleIds: number[]; emails: string[]; webhookActions: { webhookUrl: string; @@ -88,10 +106,11 @@ export type AlertRuleApiResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; recipients: { recipientId: number; userId: string | null; - roleId: string | null; + roleId: number | null; email: string | null; }[]; webhookActions: { @@ -111,26 +130,6 @@ export type AlertRuleApiResponse = { }[]; }; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function triggerToEventType( - trigger: AlertTrigger -): AlertRuleApiPayload["eventType"] { - if (trigger === "health_check_unhealthy") { - return "health_check_not_healthy"; - } - return trigger as AlertRuleApiPayload["eventType"]; -} - -function eventTypeToTrigger(eventType: string): AlertTrigger { - if (eventType === "health_check_not_healthy") { - return "health_check_unhealthy"; - } - return eventType as AlertTrigger; -} - // --------------------------------------------------------------------------- // Zod form schema (for react-hook-form validation) // --------------------------------------------------------------------------- @@ -138,44 +137,54 @@ function eventTypeToTrigger(eventType: string): AlertTrigger { export function buildFormSchema(t: (k: string) => string) { return z .object({ - name: z.string().min(1, { message: t("alertingErrorNameRequired") }), + name: z + .string() + .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), - sourceType: z.enum(["site", "health_check"]), + sourceType: z.enum(["site", "health_check", "resource"]), + allSites: z.boolean(), siteIds: z.array(z.number()), + allHealthChecks: z.boolean(), healthCheckIds: z.array(z.number()), + allResources: z.boolean(), + resourceIds: z.array(z.number()), trigger: z.enum([ "site_online", "site_offline", + "site_toggle", "health_check_healthy", - "health_check_unhealthy" + "health_check_unhealthy", + "health_check_toggle", + "resource_healthy", + "resource_unhealthy", + "resource_toggle" ]), - actions: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("notify"), - userTags: z.array(tagSchema), - roleTags: z.array(tagSchema), - emailTags: z.array(tagSchema) - }), - z.object({ - type: z.literal("webhook"), - url: z.string(), - method: z.string(), - headers: z.array( - z.object({ - key: z.string(), - value: z.string() - }) - ), - authType: z.enum(["none", "bearer", "basic", "custom"]), - bearerToken: z.string(), - basicCredentials: z.string(), - customHeaderName: z.string(), - customHeaderValue: z.string() - }) - ]) - ) + actions: z.array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userTags: z.array(tagSchema), + roleTags: z.array(tagSchema), + emailTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("webhook"), + url: z.string(), + method: z.string(), + headers: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + authType: z.enum(["none", "bearer", "basic", "custom"]), + bearerToken: z.string(), + basicCredentials: z.string(), + customHeaderName: z.string(), + customHeaderValue: z.string() + }) + ]) + ) }) .superRefine((val, ctx) => { if (val.actions.length === 0) { @@ -185,7 +194,11 @@ export function buildFormSchema(t: (k: string) => string) { path: ["actions"] }); } - if (val.sourceType === "site" && val.siteIds.length === 0) { + if ( + val.sourceType === "site" && + !val.allSites && + val.siteIds.length === 0 + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("alertingErrorPickSites"), @@ -194,6 +207,7 @@ export function buildFormSchema(t: (k: string) => string) { } if ( val.sourceType === "health_check" && + !val.allHealthChecks && val.healthCheckIds.length === 0 ) { ctx.addIssue({ @@ -202,10 +216,31 @@ export function buildFormSchema(t: (k: string) => string) { path: ["healthCheckIds"] }); } - const siteTriggers: AlertTrigger[] = ["site_online", "site_offline"]; + if ( + val.sourceType === "resource" && + !val.allResources && + val.resourceIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickResources"), + path: ["resourceIds"] + }); + } + const siteTriggers: AlertTrigger[] = [ + "site_online", + "site_offline", + "site_toggle" + ]; const hcTriggers: AlertTrigger[] = [ "health_check_healthy", - "health_check_unhealthy" + "health_check_unhealthy", + "health_check_toggle" + ]; + const resourceTriggers: AlertTrigger[] = [ + "resource_healthy", + "resource_unhealthy", + "resource_toggle" ]; if ( val.sourceType === "site" && @@ -227,6 +262,16 @@ export function buildFormSchema(t: (k: string) => string) { path: ["trigger"] }); } + if ( + val.sourceType === "resource" && + !resourceTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerResource"), + path: ["trigger"] + }); + } val.actions.forEach((a, i) => { if (a.type === "notify") { if ( @@ -265,9 +310,13 @@ export function defaultFormValues(): AlertRuleFormValues { name: "", enabled: true, sourceType: "site", + allSites: true, siteIds: [], + allHealthChecks: true, healthCheckIds: [], - trigger: "site_offline", + allResources: true, + resourceIds: [], + trigger: "site_toggle", actions: [ { type: "notify", @@ -286,9 +335,11 @@ export function defaultFormValues(): AlertRuleFormValues { export function apiResponseToFormValues( rule: AlertRuleApiResponse ): AlertRuleFormValues { - const trigger = eventTypeToTrigger(rule.eventType); + const trigger = rule.eventType; const sourceType = rule.eventType.startsWith("site_") ? "site" + : rule.eventType.startsWith("resource_") + ? "resource" : "health_check"; // Collect notify recipients into a single notify action (if any) @@ -297,7 +348,7 @@ export function apiResponseToFormValues( .map((r) => ({ id: r.userId!, text: r.userId! })); const roleTags = rule.recipients .filter((r) => r.roleId != null) - .map((r) => ({ id: r.roleId!, text: r.roleId! })); + .map((r) => ({ id: String(r.roleId!), text: String(r.roleId!) })); const emailTags = rule.recipients .filter((r) => r.email != null) .map((r) => ({ id: r.email!, text: r.email! })); @@ -318,7 +369,9 @@ export function apiResponseToFormValues( headers: cfg?.headers?.length ? cfg.headers : [{ key: "", value: "" }], - authType: (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? "none", + authType: + (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? + "none", bearerToken: cfg?.bearerToken ?? "", basicCredentials: cfg?.basicCredentials ?? "", customHeaderName: cfg?.customHeaderName ?? "", @@ -336,13 +389,23 @@ export function apiResponseToFormValues( }); } + const allSites = sourceType === "site" && rule.siteIds.length === 0; + const allHealthChecks = + sourceType === "health_check" && rule.healthCheckIds.length === 0; + const allResources = + sourceType === "resource" && (rule.resourceIds?.length ?? 0) === 0; + return { name: rule.name, enabled: rule.enabled, sourceType, + allSites, siteIds: rule.siteIds, + allHealthChecks, healthCheckIds: rule.healthCheckIds, - trigger, + allResources, + resourceIds: rule.resourceIds ?? [], + trigger: trigger as AlertTrigger, actions }; } @@ -354,11 +417,11 @@ export function apiResponseToFormValues( export function formValuesToApiPayload( values: AlertRuleFormValues ): AlertRuleApiPayload { - const eventType = triggerToEventType(values.trigger); + const eventType = values.trigger; // Collect all notify-type actions and merge their recipient lists const allUserIds: string[] = []; - const allRoleIds: string[] = []; + const allRoleIds: number[] = []; const allEmails: string[] = []; const webhookActions: AlertRuleApiPayload["webhookActions"] = []; @@ -366,11 +429,9 @@ export function formValuesToApiPayload( for (const action of values.actions) { if (action.type === "notify") { allUserIds.push(...action.userTags.map((t) => t.id)); - allRoleIds.push(...action.roleTags.map((t) => t.id)); + allRoleIds.push(...action.roleTags.map((t) => Number(t.id))); allEmails.push( - ...action.emailTags - .map((t) => t.text.trim()) - .filter(Boolean) + ...action.emailTags.map((t) => t.text.trim()).filter(Boolean) ); } else if (action.type === "webhook") { webhookActions.push({ @@ -391,15 +452,19 @@ export function formValuesToApiPayload( // Deduplicate const uniqueUserIds = [...new Set(allUserIds)]; - const uniqueRoleIds = [...new Set(allRoleIds)]; + const uniqueRoleIds: number[] = [...new Set(allRoleIds)]; const uniqueEmails = [...new Set(allEmails)]; return { name: values.name.trim(), eventType, enabled: values.enabled, - siteIds: values.siteIds, - healthCheckIds: values.healthCheckIds, + allSites: values.allSites, + siteIds: values.allSites ? [] : values.siteIds, + allHealthChecks: values.allHealthChecks, + healthCheckIds: values.allHealthChecks ? [] : values.healthCheckIds, + allResources: values.allResources, + resourceIds: values.allResources ? [] : values.resourceIds, userIds: uniqueUserIds, roleIds: uniqueRoleIds, emails: uniqueEmails, diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts deleted file mode 100644 index 2471219b0..000000000 --- a/src/lib/alertRulesLocalStorage.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { z } from "zod"; - -const STORAGE_PREFIX = "pangolin:alert-rules:"; - -export const webhookHeaderEntrySchema = z.object({ - key: z.string(), - value: z.string() -}); - -export const alertActionSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("notify"), - userIds: z.array(z.string()), - roleIds: z.array(z.number()), - emails: z.array(z.string()) - }), - z.object({ - type: z.literal("webhook"), - url: z.string().url(), - method: z.string().min(1), - headers: z.array(webhookHeaderEntrySchema), - secret: z.string().optional() - }) -]); - -export const alertSourceSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("site"), - siteIds: z.array(z.number()) - }), - z.object({ - type: z.literal("health_check"), - targetIds: z.array(z.number()) - }) -]); - -export const alertTriggerSchema = z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_unhealthy" -]); - -export const alertRuleSchema = z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(255), - enabled: z.boolean(), - createdAt: z.string(), - updatedAt: z.string(), - source: alertSourceSchema, - trigger: alertTriggerSchema, - actions: z.array(alertActionSchema).min(1) -}); - -export type AlertRule = z.infer; -export type AlertAction = z.infer; -export type AlertTrigger = z.infer; - -function storageKey(orgId: string) { - return `${STORAGE_PREFIX}${orgId}`; -} - -export function getRule(orgId: string, ruleId: string): AlertRule | undefined { - return loadRules(orgId).find((r) => r.id === ruleId); -} - -export function loadRules(orgId: string): AlertRule[] { - if (typeof window === "undefined") { - return []; - } - try { - const raw = localStorage.getItem(storageKey(orgId)); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) { - return []; - } - const out: AlertRule[] = []; - for (const item of parsed) { - const r = alertRuleSchema.safeParse(item); - if (r.success) { - out.push(r.data); - } - } - return out; - } catch { - return []; - } -} - -export function saveRules(orgId: string, rules: AlertRule[]) { - if (typeof window === "undefined") { - return; - } - localStorage.setItem(storageKey(orgId), JSON.stringify(rules)); -} - -export function upsertRule(orgId: string, rule: AlertRule) { - const rules = loadRules(orgId); - const i = rules.findIndex((r) => r.id === rule.id); - if (i >= 0) { - rules[i] = rule; - } else { - rules.push(rule); - } - saveRules(orgId, rules); -} - -export function deleteRule(orgId: string, ruleId: string) { - const rules = loadRules(orgId).filter((r) => r.id !== ruleId); - saveRules(orgId, rules); -} - -export function newRuleId() { - if (typeof crypto !== "undefined" && crypto.randomUUID) { - return crypto.randomUUID(); - } - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -export function isoNow() { - return new Date().toISOString(); -} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index c4b0a4bce..1e7074e3a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,7 +1,8 @@ import { build } from "@server/build"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { ListClientsResponse } from "@server/routers/client"; -import type { ListDomainsResponse } from "@server/routers/domain"; +import type { ListDomainsResponse, GetDNSRecordsResponse } from "@server/routers/domain"; +import type { GetDomainResponse } from "@server/routers/domain/getDomain"; import type { GetResourceWhitelistResponse, ListResourceNamesResponse, @@ -255,26 +256,88 @@ export const orgQueries = { } }), - alertRules: ({ orgId }: { orgId: string }) => + alertRules: ({ + orgId, + limit = 20, + offset = 0, + query, + siteId, + resourceId + }: { + orgId: string; + limit?: number; + offset?: number; + query?: string; + siteId?: number; + resourceId?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "ALERT_RULES"] as const, + queryKey: ["ORG", orgId, "ALERT_RULES", { limit, offset, query, siteId, resourceId }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams(); + sp.set("limit", String(limit)); + sp.set("offset", String(offset)); + if (query) sp.set("query", query); + if (siteId != null) sp.set("siteId", String(siteId)); + if (resourceId != null) sp.set("resourceId", String(resourceId)); const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/alert-rules`, { signal }); + >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal }); + return { + alertRules: res.data.data.alertRules, + pagination: res.data.data.pagination + }; + } + }), + + alertRulesForSource: ({ + orgId, + siteId, + resourceId + }: { + orgId: string; + siteId?: number; + resourceId?: number; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "ALERT_RULES", { siteId, resourceId }] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams(); + if (siteId != null) sp.set("siteId", String(siteId)); + if (resourceId != null) sp.set("resourceId", String(resourceId)); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal }); return res.data.data.alertRules; } }), - standaloneHealthChecks: ({ orgId }: { orgId: string }) => + standaloneHealthChecks: ({ + orgId, + limit = 20, + offset = 0, + query + }: { + orgId: string; + limit?: number; + offset?: number; + query?: string; + }) => queryOptions({ - queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] as const, + queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset, query }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams(); + sp.set("limit", String(limit)); + sp.set("offset", String(offset)); + if (query) sp.set("query", query); const res = await meta!.api.get< AxiosResponse<{ healthChecks: { targetHealthCheckId: number; name: string; + siteId: number | null; + siteName: string | null; + siteNiceId: string | null; hcEnabled: boolean; hcHealth: "unknown" | "healthy" | "unhealthy"; hcMode: string | null; @@ -302,8 +365,11 @@ export const orgQueries = { offset: number; }; }> - >(`/org/${orgId}/health-checks`, { signal }); - return res.data.data.healthChecks; + >(`/org/${orgId}/health-checks?${sp.toString()}`, { signal }); + return { + healthChecks: res.data.data.healthChecks, + pagination: res.data.data.pagination + }; } }), siteStatusHistory: ({ @@ -608,3 +674,49 @@ export const approvalQueries = { } }) }; + +export const domainQueries = { + getDomain: ({ + orgId, + domainId + }: { + orgId: string; + domainId: string; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "DOMAIN", domainId] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/domain/${domainId}`, { signal }); + return res.data.data; + }, + refetchInterval: durationToMs(10, "seconds") + }), + getDNSRecords: ({ + orgId, + domainId + }: { + orgId: string; + domainId: string; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "DOMAIN", + domainId, + "DNS_RECORDS" + ] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/domain/${domainId}/dns-records`, + { signal } + ); + return res.data.data; + }, + refetchInterval: durationToMs(10, "seconds") + }) +};